From 82e8ce3383540b56214ed71122e1e4e2f70f8379 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 6 Oct 2025 17:06:30 -0400 Subject: [PATCH 01/90] Add SaferSafes as child of the module and guard --- .../snapshots/abi/LivenessModule2.json | 2 +- .../snapshots/abi/SaferSafes.json | 899 ++++++++++++++++++ .../snapshots/abi/TimelockGuard.json | 2 +- .../snapshots/semver-lock.json | 12 +- .../snapshots/storageLayout/SaferSafes.json | 23 + .../src/safe/LivenessModule2.sol | 11 +- .../contracts-bedrock/src/safe/SaferSafes.sol | 58 ++ .../src/safe/TimelockGuard.sol | 11 +- .../test/safe/SaferSafes.t.sol | 144 +++ 9 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 packages/contracts-bedrock/snapshots/abi/SaferSafes.json create mode 100644 packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json create mode 100644 packages/contracts-bedrock/src/safe/SaferSafes.sol create mode 100644 packages/contracts-bedrock/test/safe/SaferSafes.t.sol diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 8a4658b443d8a..4b4f2ca5514e4 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -136,7 +136,7 @@ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json new file mode 100644 index 0000000000000..aac3825c42578 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -0,0 +1,899 @@ +[ + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_signatures", + "type": "bytes" + } + ], + "name": "cancelTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "cancellationThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "challenge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "challengeStartTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "changeOwnershipToFallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "_success", + "type": "bool" + } + ], + "name": "checkAfterExecution", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "_operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_gasToken", + "type": "address" + }, + { + "internalType": "address payable", + "name": "_refundReceiver", + "type": "address" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "checkTransaction", + "outputs": [], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "clearLivenessModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "internalType": "struct LivenessModule2.ModuleConfig", + "name": "_config", + "type": "tuple" + } + ], + "name": "configureLivenessModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_timelockDelay", + "type": "uint256" + } + ], + "name": "configureTimelockGuard", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "getChallengePeriodEnd", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "livenessSafeConfiguration", + "outputs": [ + { + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "maxCancellationThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "pendingTransactions", + "outputs": [ + { + "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", + "type": "function" + }, + { + "inputs": [], + "name": "respond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "internalType": "address payable", + "name": "refundReceiver", + "type": "address" + } + ], + "internalType": "struct TimelockGuard.ExecTransactionParams", + "name": "_params", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "_signatures", + "type": "bytes" + } + ], + "name": "scheduleTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" + } + ], + "name": "scheduledTransaction", + "outputs": [ + { + "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", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "signCancellation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "timelockConfiguration", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldThreshold", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newThreshold", + "type": "uint256" + } + ], + "name": "CancellationThresholdUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "ChallengeCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "challengeStartTime", + "type": "uint256" + } + ], + "name": "ChallengeStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ChallengeSucceeded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + } + ], + "name": "GuardConfigured", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "message", + "type": "string" + } + ], + "name": "Message", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "ModuleCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ModuleConfigured", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + } + ], + "name": "TransactionCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "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": "txHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "executionTime", + "type": "uint256" + } + ], + "name": "TransactionScheduled", + "type": "event" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeAlreadyExists", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidFallbackOwner", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidResponsePeriod", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleStillEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_OwnershipTransferFailed", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ResponsePeriodActive", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ResponsePeriodEnded", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_UnauthorizedCaller", + "type": "error" + }, + { + "inputs": [], + "name": "SaferSafes_InsufficientLivenessResponsePeriod", + "type": "error" + }, + { + "inputs": [], + "name": "SemverComp_InvalidSemverParts", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_GuardNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_GuardNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_InvalidTimelockDelay", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_InvalidVersion", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionAlreadyCancelled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionAlreadyExecuted", + "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/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json index 15145753d43b0..b9889a26fd87c 100644 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -447,7 +447,7 @@ "type": "string" } ], - "stateMutability": "view", + "stateMutability": "pure", "type": "function" }, { diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 366cbca9da7c8..bf54fdd327985 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,12 +208,16 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x4679b41e5648a955a883efd0271453c8b13ff4846f853d372527ebb1e0905ab5", - "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" + "initCodeHash": "0x4e0c8b2447125cfccf3ed411307cd3d18133b5d470c3fd71d570364e306d7d8a", + "sourceCodeHash": "0x31af1d434615f99e7d60599cf56c2f9d931f2cca313e6305b5f7c2d2fdc7c295" + }, + "src/safe/SaferSafes.sol:SaferSafes": { + "initCodeHash": "0xfe351a63c1c4e49b1f3365d79ca04eca5a52c2b5fe40b097b10537e18cb32025", + "sourceCodeHash": "0xba014462737e4b0579bd8211f286a4888a5413b32eba631b0f9a001eb412af3a" }, "src/safe/TimelockGuard.sol:TimelockGuard": { - "initCodeHash": "0x1f8188872de93ce59e8f0bd415d4fbf30209bc668c09623f61d6fe592eee895a", - "sourceCodeHash": "0x0dada93f051d29dabbb6de3e1c1ece14b95cd20dc854454926d19ea1ebcae436" + "initCodeHash": "0xff4d54c8e59f78611e9ac29565588d33793022cdb5d32e10c2b035b027293319", + "sourceCodeHash": "0xc9de0e490313568c0428ca23f3d508d0261bc32eae979eafd05193ebb364f2d9" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json b/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json new file mode 100644 index 0000000000000..17b7d237e9f5f --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json @@ -0,0 +1,23 @@ +[ + { + "bytes": "32", + "label": "livenessSafeConfiguration", + "offset": 0, + "slot": "0", + "type": "mapping(address => struct LivenessModule2.ModuleConfig)" + }, + { + "bytes": "32", + "label": "challengeStartTime", + "offset": 0, + "slot": "1", + "type": "mapping(address => uint256)" + }, + { + "bytes": "32", + "label": "_safeState", + "offset": 0, + "slot": "2", + "type": "mapping(contract GnosisSafe => struct TimelockGuard.SafeState)" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 457f8678910c8..3f3867a19c474 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -99,7 +99,9 @@ contract LivenessModule2 is ISemver { /// @notice Semantic version. /// @custom:semver 2.0.0 - string public constant version = "2.0.0"; + function version() public pure virtual returns (string memory) { + return "2.0.0"; + } /// @notice Returns challenge_start_time + liveness_response_period if challenge exists, or /// 0 if not. @@ -146,8 +148,15 @@ contract LivenessModule2 is ISemver { _cancelChallenge(msg.sender); emit ModuleConfigured(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); + + // Verify that any other extensions which are enabled on the Safe are configured correctly. + _checkCombinedConfig(Safe(payable(msg.sender))); } + /// @notice Internal helper function which can be overriden in a child contract to check if the guard's + /// configuration is valid in the context of other extensions that are enabled on the Safe. + function _checkCombinedConfig(Safe _safe) internal view virtual { } + /// @notice Clears the module configuration for a Safe. /// @dev Note: Clearing the configuration also cancels any ongoing challenges. /// This function is intended for use when a Safe wants to permanently remove diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol new file mode 100644 index 0000000000000..fcb05169826d2 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Safe +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; + +// Safe Extensions +import { LivenessModule2 } from "./LivenessModule2.sol"; +import { TimelockGuard } from "./TimelockGuard.sol"; + +/// @title SaferSafes +/// @notice Combined Safe extensions providing both liveness module and timelock guard functionality +/// @dev This contract can be enabled simultaneously as both a module and a guard on a Safe: +/// - As a module: provides liveness challenge functionality to prevent multisig deadlock +/// - As a guard: provides timelock functionality for transaction delays and cancellation +contract SaferSafes is LivenessModule2, TimelockGuard { + /// @notice Error for when the liveness response period is insufficient. + error SaferSafes_InsufficientLivenessResponsePeriod(); + + /// @notice Semantic version. + /// @custom:semver 1.0.0 + function version() public pure override(LivenessModule2, TimelockGuard) returns (string memory) { + return "1.0.0"; + } + + /// @notice Internal helper function which can be overriden in a child contract to check if the guard's + /// configuration is valid in the context of other extensions that are enabled on the Safe. + /// This function acts as a FREI-PI invariant check to ensure the resulting config is valid, it MUST be + /// called at the end of any configuration functions in the parent contract. + function _checkCombinedConfig(Safe _safe) internal view override(LivenessModule2, TimelockGuard) { + // We only need to perform this check if both the guard and the module are enabled on the Safe + if (!(_isGuardEnabled(_safe) && _safe.isModuleEnabled(address(this)))) { + return; + } + + uint256 timelockDelay = _safeState[_safe].timelockDelay; + uint256 livenessResponsePeriod = livenessSafeConfiguration[address(_safe)].livenessResponsePeriod; + + // If the timelock delay is 0, then the timelock guard is enabled but not configured. + // No delay is applied to transactions, so we don't need to perform any further checks. + if (timelockDelay == 0) { + return; + } + + // If the liveness response period is 0, then the liveness module is enabled but not configured. + // Challenging is not possible, so we don't need to perform any further checks. + if (livenessResponsePeriod == 0) { + return; + } + + // The liveness response period must be at least twice the timelock delay, this is necessary to prevent a + // situation in which a + // Safe is not able to respond to a challenge. + if (livenessResponsePeriod < 2 * timelockDelay) { + revert SaferSafes_InsufficientLivenessResponsePeriod(); + } + } +} diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 26dce92ce472e..b7a66cecb4c9c 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -127,7 +127,9 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Semantic version. /// @custom:semver 1.0.0 - string public constant version = "1.0.0"; + function version() public pure virtual returns (string memory) { + return "1.0.0"; + } /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -209,6 +211,10 @@ contract TimelockGuard is IGuard, ISemver { return guard == address(this); } + /// @notice Internal helper function which can be overriden in a child contract to check if the guard's + /// configuration is valid in the context of other extensions that are enabled on the Safe. + function _checkCombinedConfig(Safe _safe) internal view virtual { } + //////////////////////////////////////////////////////////////// // External View Functions // //////////////////////////////////////////////////////////////// @@ -451,6 +457,9 @@ contract TimelockGuard is IGuard, ISemver { // Initialize (or reset) the cancellation threshold to 1. _resetCancellationThreshold(callingSafe); emit GuardConfigured(callingSafe, _timelockDelay); + + // Verify that any other extensions which are enabled on the Safe are configured correctly. + _checkCombinedConfig(callingSafe); } /// @notice Schedule a transaction for execution after the timelock delay. diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol new file mode 100644 index 0000000000000..8f8b74ebd292f --- /dev/null +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "forge-std/Test.sol"; +import "test/safe-tools/SafeTestTools.sol"; + +import { SaferSafes } from "src/safe/SaferSafes.sol"; +import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; + +/// @title SaferSafes_TestInit +/// @notice Reusable test initialization for `SaferSafes` tests. +contract SaferSafes_TestInit is Test, SafeTestTools { + using SafeTestLib for SafeInstance; + + // Events + event ModuleConfigured(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); + event GuardConfigured(address indexed safe, uint256 timelockDelay, uint256 cancellationThreshold); + + uint256 constant INIT_TIME = 10; + uint256 constant NUM_OWNERS = 5; + uint256 constant THRESHOLD = 3; + + SaferSafes saferSafes; + SafeInstance safeInstance; + address fallbackOwner; + address[] owners; + uint256[] ownerPKs; + + function setUp() public virtual { + vm.warp(INIT_TIME); + + // Deploy the SaferSafes contract + saferSafes = new SaferSafes(); + + // 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); + + // Set fallback owner + fallbackOwner = makeAddr("fallbackOwner"); + + // Enable the module and guard on the Safe + safeInstance.enableModule(address(saferSafes)); + safeInstance.setGuard(address(saferSafes)); + } +} + +/// @title SaferSafes_Configure_Test +/// @notice Tests for SaferSafes configuration functionality. +contract SaferSafes_Configure_Test is SaferSafes_TestInit { + /// @notice Test successful configuration when liveness response period is at least 2x timelock delay. + function test_configure_livenessModuleFirst_succeeds() public { + uint256 timelockDelay = 7 days; + uint256 livenessResponsePeriod = 21 days; // Much greater than 2 * 7 days = 14 days (should succeed) + + // Configure the liveness module FIRST + LivenessModule2.ModuleConfig memory moduleConfig = LivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessResponsePeriod, + fallbackOwner: fallbackOwner + }); + + vm.prank(address(safeInstance.safe)); + saferSafes.configureLivenessModule(moduleConfig); + + // Configure the timelock guard SECOND (this will trigger the check) + vm.prank(address(safeInstance.safe)); + saferSafes.configureTimelockGuard(timelockDelay); + + // Verify configurations were set + (uint256 storedLivenessResponsePeriod, address storedFallbackOwner) = + saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); + assertEq(storedFallbackOwner, fallbackOwner); + } + + function test_configure_timelockGuardFirst_succeeds() public { + uint256 timelockDelay = 7 days; + uint256 livenessResponsePeriod = 21 days; // Much greater than 2 * 7 days = 14 days (should succeed) + + // Configure the timelock guard FIRST + vm.prank(address(safeInstance.safe)); + saferSafes.configureTimelockGuard(timelockDelay); + + LivenessModule2.ModuleConfig memory moduleConfig = LivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessResponsePeriod, + fallbackOwner: fallbackOwner + }); + + // Configure the liveness module SECOND (this will trigger the check) + vm.prank(address(safeInstance.safe)); + saferSafes.configureLivenessModule(moduleConfig); + + // Verify configurations were set + (uint256 storedLivenessResponsePeriod, address storedFallbackOwner) = + saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); + assertEq(storedFallbackOwner, fallbackOwner); + } + + /// @notice Test that attempting to enable the second component with incompatible config fails. + /// @dev This test would fail if timelock guard configuration also triggered validation + function test_configure_livenessModuleFirstInvalidConfig_reverts() public { + uint256 timelockDelay = 7 days; + uint256 livenessResponsePeriod = 13 days; // This is invalid: 13 < 2*7 + + // Configure liveness module first + LivenessModule2.ModuleConfig memory moduleConfig = LivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessResponsePeriod, + fallbackOwner: fallbackOwner + }); + + vm.prank(address(safeInstance.safe)); + saferSafes.configureLivenessModule(moduleConfig); + + // Now configure timelock guard - this depends on when validation is triggered + // Based on current behavior, this should succeed because validation doesn't happen during reconfiguration + vm.prank(address(safeInstance.safe)); + vm.expectRevert(SaferSafes.SaferSafes_InsufficientLivenessResponsePeriod.selector); + saferSafes.configureTimelockGuard(timelockDelay); + } + + function test_configure_timelockGuardFirstInvalidConfig_reverts() public { + uint256 timelockDelay = 7 days; + uint256 livenessResponsePeriod = 13 days; // This is invalid: 13 < 2*7 + + // Configure timelock guard first + vm.prank(address(safeInstance.safe)); + saferSafes.configureTimelockGuard(timelockDelay); + + LivenessModule2.ModuleConfig memory moduleConfig = LivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessResponsePeriod, + fallbackOwner: fallbackOwner + }); + + // Configure liveness module second - this will trigger the check + vm.expectRevert(SaferSafes.SaferSafes_InsufficientLivenessResponsePeriod.selector); + vm.prank(address(safeInstance.safe)); + saferSafes.configureLivenessModule(moduleConfig); + } +} From b6a55613012b690821f25d4da693462539d80ca8 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 13:03:30 -0400 Subject: [PATCH 02/90] Add ISaferSafes --- .../interfaces/safe/ISaferSafes.sol | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol diff --git a/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol b/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol new file mode 100644 index 0000000000000..630bf051f83c8 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import {GnosisSafe} from "safe-contracts/GnosisSafe.sol"; +import {Enum} from "safe-contracts/common/Enum.sol"; + +interface ISaferSafes { + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } + + struct ExecTransactionParams { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + } + + enum TransactionState { + PENDING, + CANCELLED, + EXECUTED + } + + struct ScheduledTransaction { + uint256 executionTime; + TransactionState state; + ExecTransactionParams params; + } + + event CancellationThresholdUpdated( + GnosisSafe indexed safe, + uint256 oldThreshold, + uint256 newThreshold + ); + event ChallengeCancelled(address indexed safe); + event ChallengeStarted(address indexed safe, uint256 challengeStartTime); + event ChallengeSucceeded(address indexed safe, address fallbackOwner); + event GuardConfigured(GnosisSafe indexed safe, uint256 timelockDelay); + event Message(string message); + event ModuleCleared(address indexed safe); + event ModuleConfigured( + address indexed safe, + uint256 livenessResponsePeriod, + address fallbackOwner + ); + event TransactionCancelled(GnosisSafe indexed safe, bytes32 indexed txHash); + event TransactionExecuted(GnosisSafe indexed safe, bytes32 txHash); + event TransactionScheduled( + GnosisSafe indexed safe, + bytes32 indexed txHash, + uint256 executionTime + ); + + error LivenessModule2_ChallengeAlreadyExists(); + error LivenessModule2_ChallengeDoesNotExist(); + error LivenessModule2_InvalidFallbackOwner(); + error LivenessModule2_InvalidResponsePeriod(); + error LivenessModule2_ModuleNotConfigured(); + error LivenessModule2_ModuleNotEnabled(); + error LivenessModule2_ModuleStillEnabled(); + error LivenessModule2_OwnershipTransferFailed(); + error LivenessModule2_ResponsePeriodActive(); + error LivenessModule2_ResponsePeriodEnded(); + error LivenessModule2_UnauthorizedCaller(); + error SaferSafes_InsufficientLivenessResponsePeriod(); + error SemverComp_InvalidSemverParts(); + error TimelockGuard_GuardNotConfigured(); + error TimelockGuard_GuardNotEnabled(); + error TimelockGuard_InvalidTimelockDelay(); + error TimelockGuard_InvalidVersion(); + error TimelockGuard_TransactionAlreadyCancelled(); + error TimelockGuard_TransactionAlreadyExecuted(); + error TimelockGuard_TransactionAlreadyScheduled(); + error TimelockGuard_TransactionNotReady(); + error TimelockGuard_TransactionNotScheduled(); + + function cancelTransaction( + GnosisSafe _safe, + bytes32 _txHash, + uint256 _nonce, + bytes calldata _signatures + ) external; + + function cancellationThreshold( + GnosisSafe _safe + ) external view returns (uint256); + + function challenge(address _safe) external; + + function challengeStartTime(address _safe) external view returns (uint256); + + function changeOwnershipToFallback(address _safe) external; + + function checkAfterExecution(bytes32 _txHash, bool _success) external; + + function checkTransaction( + address _to, + uint256 _value, + bytes calldata _data, + Enum.Operation _operation, + uint256 _safeTxGas, + uint256 _baseGas, + uint256 _gasPrice, + address _gasToken, + address payable _refundReceiver, + bytes calldata, + address + ) external view; + + function clearLivenessModule() external; + + function configureLivenessModule(ModuleConfig calldata _config) external; + + function configureTimelockGuard(uint256 _timelockDelay) external; + + function getChallengePeriodEnd( + address _safe + ) external view returns (uint256); + + function livenessSafeConfiguration( + address _safe + ) + external + view + returns (uint256 livenessResponsePeriod, address fallbackOwner); + + function maxCancellationThreshold( + GnosisSafe _safe + ) external view returns (uint256); + + function pendingTransactions( + GnosisSafe _safe + ) external view returns (ScheduledTransaction[] memory); + + function respond() external; + + function scheduleTransaction( + GnosisSafe _safe, + uint256 _nonce, + ExecTransactionParams calldata _params, + bytes calldata _signatures + ) external; + + function scheduledTransaction( + GnosisSafe _safe, + bytes32 _txHash + ) external view returns (ScheduledTransaction memory); + + function signCancellation(bytes32 _txHash) external; + + function timelockConfiguration( + GnosisSafe _safe + ) external view returns (uint256); + + function version() external pure returns (string memory); +} From 3a33850284843e1bcfc66cfbae6ced6ead9cb148 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 13:03:48 -0400 Subject: [PATCH 03/90] Test comment and assertion fixes --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 8f8b74ebd292f..a0355a33112cf 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -75,6 +75,7 @@ contract SaferSafes_Configure_Test is SaferSafes_TestInit { saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); assertEq(storedFallbackOwner, fallbackOwner); + assertEq(saferSafes.timelockConfiguration(safeInstance.safe), timelockDelay); } function test_configure_timelockGuardFirst_succeeds() public { @@ -99,9 +100,11 @@ contract SaferSafes_Configure_Test is SaferSafes_TestInit { saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); assertEq(storedFallbackOwner, fallbackOwner); + assertEq(saferSafes.timelockConfiguration(safeInstance.safe), timelockDelay); } - /// @notice Test that attempting to enable the second component with incompatible config fails. + /// @notice Test that attempting to incorrectly configure the timelock guard after first configuring the liveness module + /// fails. /// @dev This test would fail if timelock guard configuration also triggered validation function test_configure_livenessModuleFirstInvalidConfig_reverts() public { uint256 timelockDelay = 7 days; @@ -116,8 +119,7 @@ contract SaferSafes_Configure_Test is SaferSafes_TestInit { vm.prank(address(safeInstance.safe)); saferSafes.configureLivenessModule(moduleConfig); - // Now configure timelock guard - this depends on when validation is triggered - // Based on current behavior, this should succeed because validation doesn't happen during reconfiguration + // Now configure timelock guard vm.prank(address(safeInstance.safe)); vm.expectRevert(SaferSafes.SaferSafes_InsufficientLivenessResponsePeriod.selector); saferSafes.configureTimelockGuard(timelockDelay); From 81399b22a22082af3137e324fcb79d031373cc1e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 14:22:23 -0400 Subject: [PATCH 04/90] Improve comments --- packages/contracts-bedrock/src/safe/SaferSafes.sol | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index fcb05169826d2..93b9f1b157b23 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -13,6 +13,12 @@ import { TimelockGuard } from "./TimelockGuard.sol"; /// @dev This contract can be enabled simultaneously as both a module and a guard on a Safe: /// - As a module: provides liveness challenge functionality to prevent multisig deadlock /// - As a guard: provides timelock functionality for transaction delays and cancellation +/// The two components in this contract are almost entirely independent of each other, and can be treated as +/// separate extensions to the Safe. The only shared logic is the _checkCombinedConfig which runs at the end of the +/// configuration functions for both components and ensures that the resulting configuration is valid. +/// Either component can be enabled or disabled independently of the other. +/// When installing either component, it should first be enabled, and then configured. If a component's +/// functionality is not desired, then there is no need to enable or configure it. contract SaferSafes is LivenessModule2, TimelockGuard { /// @notice Error for when the liveness response period is insufficient. error SaferSafes_InsufficientLivenessResponsePeriod(); @@ -49,8 +55,8 @@ contract SaferSafes is LivenessModule2, TimelockGuard { } // The liveness response period must be at least twice the timelock delay, this is necessary to prevent a - // situation in which a - // Safe is not able to respond to a challenge. + // situation in which a Safe is not able to respond because there is insufficient time to respond to a challenge + // after the timelock delay has expired. if (livenessResponsePeriod < 2 * timelockDelay) { revert SaferSafes_InsufficientLivenessResponsePeriod(); } From 05aa13d8890c9d53862b523ca6116f8102c99548 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 14:52:17 -0400 Subject: [PATCH 05/90] Make LivenessModule2 and TimelockGuard abstract Move semver to SaferSafes semver lock --- .../interfaces/safe/ILivenessModule2.sol | 4 +- .../interfaces/safe/ISaferSafes.sol | 3 +- .../interfaces/safe/ITimelockGuard.sol | 1 - .../scripts/deploy/DeployOwnership.s.sol | 7 +- .../snapshots/abi/LivenessModule2.json | 286 -------- .../snapshots/abi/SaferSafes.json | 2 +- .../snapshots/abi/TimelockGuard.json | 623 ------------------ .../snapshots/semver-lock.json | 12 +- .../storageLayout/LivenessModule2.json | 16 - .../storageLayout/TimelockGuard.json | 9 - .../src/safe/LivenessModule2.sol | 13 +- .../contracts-bedrock/src/safe/SaferSafes.sol | 13 +- .../src/safe/TimelockGuard.sol | 13 +- .../test/safe/LivenessModule2.t.sol | 7 +- .../test/safe/SaferSafes.t.sol | 4 +- .../test/safe/TimelockGuard.t.sol | 7 +- 16 files changed, 31 insertions(+), 989 deletions(-) delete mode 100644 packages/contracts-bedrock/snapshots/abi/LivenessModule2.json delete mode 100644 packages/contracts-bedrock/snapshots/abi/TimelockGuard.json delete mode 100644 packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json delete mode 100644 packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 64b367c50d2bd..921d1a794c776 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -1,11 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import { ISemver } from "interfaces/universal/ISemver.sol"; - /// @title ILivenessModule2 /// @notice Interface for LivenessModule2, a singleton module for challenge-based ownership transfer -interface ILivenessModule2 is ISemver { +interface ILivenessModule2 { /// @notice Configuration for a Safe's liveness module struct ModuleConfig { uint256 livenessResponsePeriod; diff --git a/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol b/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol index 630bf051f83c8..7fc50d05eda0f 100644 --- a/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol +++ b/packages/contracts-bedrock/interfaces/safe/ISaferSafes.sol @@ -3,8 +3,9 @@ pragma solidity 0.8.15; import {GnosisSafe} from "safe-contracts/GnosisSafe.sol"; import {Enum} from "safe-contracts/common/Enum.sol"; +import {ISemver} from "interfaces/universal/ISemver.sol"; -interface ISaferSafes { +interface ISaferSafes is ISemver { struct ModuleConfig { uint256 livenessResponsePeriod; address fallbackOwner; diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index ccf454985a90d..984309a87c45c 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -82,7 +82,6 @@ interface ITimelockGuard { bytes memory _signatures ) external; - function version() external view returns (string memory); function timelockConfiguration(address _safe) external view returns (uint256 timelockDelay); function maxCancellationThreshold(address _safe) external view returns (uint256); function pendingTransactions(address _safe) diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index c93f547e4f072..ebbc244e9c322 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -12,6 +12,7 @@ import { Enum as SafeOps } from "safe-contracts/common/Enum.sol"; import { DeployUtils } from "scripts/libraries/DeployUtils.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { SaferSafes } from "src/safe/SaferSafes.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; @@ -241,11 +242,11 @@ contract DeployOwnership is Deploy { /// @notice Deploy a LivenessModule2 singleton for use on Security Council Safes /// Note this function does not have the broadcast modifier. function deployLivenessModule() public returns (address addr_) { - // Deploy the singleton LivenessModule2 (no parameters needed) - addr_ = address(new LivenessModule2()); + // Deploy the singleton SaferSafes contract which implements LivenessModule2 (no parameters needed) + addr_ = address(new SaferSafes()); artifacts.save("LivenessModule2", address(addr_)); - console.log("New LivenessModule2 deployed at %s", address(addr_)); + console.log("New SaferSafes (LivenessModule2) deployed at %s", address(addr_)); } /// @notice Deploy a Security Council Safe. diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json deleted file mode 100644 index 4b4f2ca5514e4..0000000000000 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ /dev/null @@ -1,286 +0,0 @@ -[ - { - "inputs": [ - { - "internalType": "address", - "name": "_safe", - "type": "address" - } - ], - "name": "challenge", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "challengeStartTime", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_safe", - "type": "address" - } - ], - "name": "changeOwnershipToFallback", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "clearLivenessModule", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "components": [ - { - "internalType": "uint256", - "name": "livenessResponsePeriod", - "type": "uint256" - }, - { - "internalType": "address", - "name": "fallbackOwner", - "type": "address" - } - ], - "internalType": "struct LivenessModule2.ModuleConfig", - "name": "_config", - "type": "tuple" - } - ], - "name": "configureLivenessModule", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_safe", - "type": "address" - } - ], - "name": "getChallengePeriodEnd", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "livenessSafeConfiguration", - "outputs": [ - { - "internalType": "uint256", - "name": "livenessResponsePeriod", - "type": "uint256" - }, - { - "internalType": "address", - "name": "fallbackOwner", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "respond", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "version", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "safe", - "type": "address" - } - ], - "name": "ChallengeCancelled", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "safe", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "challengeStartTime", - "type": "uint256" - } - ], - "name": "ChallengeStarted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "safe", - "type": "address" - }, - { - "indexed": false, - "internalType": "address", - "name": "fallbackOwner", - "type": "address" - } - ], - "name": "ChallengeSucceeded", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "safe", - "type": "address" - } - ], - "name": "ModuleCleared", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "safe", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "livenessResponsePeriod", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "address", - "name": "fallbackOwner", - "type": "address" - } - ], - "name": "ModuleConfigured", - "type": "event" - }, - { - "inputs": [], - "name": "LivenessModule2_ChallengeAlreadyExists", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ChallengeDoesNotExist", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_InvalidFallbackOwner", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_InvalidResponsePeriod", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ModuleNotConfigured", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ModuleNotEnabled", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ModuleStillEnabled", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_OwnershipTransferFailed", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ResponsePeriodActive", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_ResponsePeriodEnded", - "type": "error" - }, - { - "inputs": [], - "name": "LivenessModule2_UnauthorizedCaller", - "type": "error" - } -] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index aac3825c42578..c79f6fa7929c0 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -574,7 +574,7 @@ "type": "string" } ], - "stateMutability": "pure", + "stateMutability": "view", "type": "function" }, { diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json deleted file mode 100644 index b9889a26fd87c..0000000000000 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ /dev/null @@ -1,623 +0,0 @@ -[ - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - }, - { - "internalType": "bytes32", - "name": "_txHash", - "type": "bytes32" - }, - { - "internalType": "uint256", - "name": "_nonce", - "type": "uint256" - }, - { - "internalType": "bytes", - "name": "_signatures", - "type": "bytes" - } - ], - "name": "cancelTransaction", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - } - ], - "name": "cancellationThreshold", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "_txHash", - "type": "bytes32" - }, - { - "internalType": "bool", - "name": "_success", - "type": "bool" - } - ], - "name": "checkAfterExecution", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "_to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "_value", - "type": "uint256" - }, - { - "internalType": "bytes", - "name": "_data", - "type": "bytes" - }, - { - "internalType": "enum Enum.Operation", - "name": "_operation", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "_safeTxGas", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_baseGas", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_gasPrice", - "type": "uint256" - }, - { - "internalType": "address", - "name": "_gasToken", - "type": "address" - }, - { - "internalType": "address payable", - "name": "_refundReceiver", - "type": "address" - }, - { - "internalType": "bytes", - "name": "", - "type": "bytes" - }, - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "checkTransaction", - "outputs": [], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "_timelockDelay", - "type": "uint256" - } - ], - "name": "configureTimelockGuard", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - } - ], - "name": "maxCancellationThreshold", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - } - ], - "name": "pendingTransactions", - "outputs": [ - { - "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", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - }, - { - "internalType": "uint256", - "name": "_nonce", - "type": "uint256" - }, - { - "components": [ - { - "internalType": "address", - "name": "to", - "type": "address" - }, - { - "internalType": "uint256", - "name": "value", - "type": "uint256" - }, - { - "internalType": "bytes", - "name": "data", - "type": "bytes" - }, - { - "internalType": "enum Enum.Operation", - "name": "operation", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "safeTxGas", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "baseGas", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "gasPrice", - "type": "uint256" - }, - { - "internalType": "address", - "name": "gasToken", - "type": "address" - }, - { - "internalType": "address payable", - "name": "refundReceiver", - "type": "address" - } - ], - "internalType": "struct TimelockGuard.ExecTransactionParams", - "name": "_params", - "type": "tuple" - }, - { - "internalType": "bytes", - "name": "_signatures", - "type": "bytes" - } - ], - "name": "scheduleTransaction", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - }, - { - "internalType": "bytes32", - "name": "_txHash", - "type": "bytes32" - } - ], - "name": "scheduledTransaction", - "outputs": [ - { - "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", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - } - ], - "name": "signCancellation", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "_safe", - "type": "address" - } - ], - "name": "timelockConfiguration", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "version", - "outputs": [ - { - "internalType": "string", - "name": "", - "type": "string" - } - ], - "stateMutability": "pure", - "type": "function" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "contract GnosisSafe", - "name": "safe", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "oldThreshold", - "type": "uint256" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "newThreshold", - "type": "uint256" - } - ], - "name": "CancellationThresholdUpdated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "contract GnosisSafe", - "name": "safe", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "timelockDelay", - "type": "uint256" - } - ], - "name": "GuardConfigured", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "string", - "name": "message", - "type": "string" - } - ], - "name": "Message", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "contract GnosisSafe", - "name": "safe", - "type": "address" - }, - { - "indexed": true, - "internalType": "bytes32", - "name": "txHash", - "type": "bytes32" - } - ], - "name": "TransactionCancelled", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "contract GnosisSafe", - "name": "safe", - "type": "address" - }, - { - "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": "txHash", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "executionTime", - "type": "uint256" - } - ], - "name": "TransactionScheduled", - "type": "event" - }, - { - "inputs": [], - "name": "SemverComp_InvalidSemverParts", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_GuardNotConfigured", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_GuardNotEnabled", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_InvalidTimelockDelay", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_InvalidVersion", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_TransactionAlreadyCancelled", - "type": "error" - }, - { - "inputs": [], - "name": "TimelockGuard_TransactionAlreadyExecuted", - "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/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index bf54fdd327985..c0eb064250f48 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -207,17 +207,9 @@ "initCodeHash": "0xa4a06e8778dbb6883ece8f56538ba15bc01b3031bba9a12ad9d187e7c8aaa942", "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, - "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x4e0c8b2447125cfccf3ed411307cd3d18133b5d470c3fd71d570364e306d7d8a", - "sourceCodeHash": "0x31af1d434615f99e7d60599cf56c2f9d931f2cca313e6305b5f7c2d2fdc7c295" - }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0xfe351a63c1c4e49b1f3365d79ca04eca5a52c2b5fe40b097b10537e18cb32025", - "sourceCodeHash": "0xba014462737e4b0579bd8211f286a4888a5413b32eba631b0f9a001eb412af3a" - }, - "src/safe/TimelockGuard.sol:TimelockGuard": { - "initCodeHash": "0xff4d54c8e59f78611e9ac29565588d33793022cdb5d32e10c2b035b027293319", - "sourceCodeHash": "0xc9de0e490313568c0428ca23f3d508d0261bc32eae979eafd05193ebb364f2d9" + "initCodeHash": "0x22730f6ebe10e0d78b23b3e0f3d58f867f420039981455b09a508755e512c127", + "sourceCodeHash": "0xe3d2fd50724baf6d5e83e2c9d89fcfcbc678d966187bd38e2d9a840716bcafa3" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json deleted file mode 100644 index 478b0b25136c3..0000000000000 --- a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "bytes": "32", - "label": "livenessSafeConfiguration", - "offset": 0, - "slot": "0", - "type": "mapping(address => struct LivenessModule2.ModuleConfig)" - }, - { - "bytes": "32", - "label": "challengeStartTime", - "offset": 0, - "slot": "1", - "type": "mapping(address => uint256)" - } -] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json deleted file mode 100644 index 97c754bfc8c5a..0000000000000 --- a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { - "bytes": "32", - "label": "_safeState", - "offset": 0, - "slot": "0", - "type": "mapping(contract GnosisSafe => struct TimelockGuard.SafeState)" - } -] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 3f3867a19c474..ac9fe40839ee6 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -6,9 +6,6 @@ import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; -// Interfaces -import { ISemver } from "interfaces/universal/ISemver.sol"; - /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner /// when the Safe becomes unresponsive. The fallback owner can initiate a challenge, @@ -17,7 +14,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// @dev This is a singleton contract. To use it: /// 1. The Safe must first enable this module using ModuleManager.enableModule() /// 2. The Safe must then configure the module by calling configure() with params -contract LivenessModule2 is ISemver { +abstract contract LivenessModule2 { /// @notice Configuration for a Safe's liveness module. /// @custom:field livenessResponsePeriod The duration in seconds that Safe owners have to /// respond to a challenge. @@ -97,12 +94,6 @@ contract LivenessModule2 is ISemver { /// @param fallbackOwner The address that claimed ownership if the Safe is unresponsive. event ChallengeSucceeded(address indexed safe, address fallbackOwner); - /// @notice Semantic version. - /// @custom:semver 2.0.0 - function version() public pure virtual returns (string memory) { - return "2.0.0"; - } - /// @notice Returns challenge_start_time + liveness_response_period if challenge exists, or /// 0 if not. /// @param _safe The Safe address to query. @@ -155,7 +146,7 @@ contract LivenessModule2 is ISemver { /// @notice Internal helper function which can be overriden in a child contract to check if the guard's /// configuration is valid in the context of other extensions that are enabled on the Safe. - function _checkCombinedConfig(Safe _safe) internal view virtual { } + function _checkCombinedConfig(Safe _safe) internal view virtual; /// @notice Clears the module configuration for a Safe. /// @dev Note: Clearing the configuration also cancels any ongoing challenges. diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 93b9f1b157b23..e468652678a33 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -7,6 +7,7 @@ import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; // Safe Extensions import { LivenessModule2 } from "./LivenessModule2.sol"; import { TimelockGuard } from "./TimelockGuard.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; /// @title SaferSafes /// @notice Combined Safe extensions providing both liveness module and timelock guard functionality @@ -19,15 +20,13 @@ import { TimelockGuard } from "./TimelockGuard.sol"; /// Either component can be enabled or disabled independently of the other. /// When installing either component, it should first be enabled, and then configured. If a component's /// functionality is not desired, then there is no need to enable or configure it. -contract SaferSafes is LivenessModule2, TimelockGuard { - /// @notice Error for when the liveness response period is insufficient. - error SaferSafes_InsufficientLivenessResponsePeriod(); - +contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Semantic version. /// @custom:semver 1.0.0 - function version() public pure override(LivenessModule2, TimelockGuard) returns (string memory) { - return "1.0.0"; - } + string public constant version = "1.0.0"; + + /// @notice Error for when the liveness response period is insufficient. + error SaferSafes_InsufficientLivenessResponsePeriod(); /// @notice Internal helper function which can be overriden in a child contract to check if the guard's /// configuration is valid in the context of other extensions that are enabled on the Safe. diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index b7a66cecb4c9c..183b2a46c8e70 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -10,9 +10,6 @@ import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { SemverComp } from "src/libraries/SemverComp.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, any Safe on the network can use this guard to enforce a timelock delay, and @@ -67,7 +64,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// | Quorum+ | challenge + | cancelTransaction | /// | | changeOwnershipToFallback | | /// +-------------------------------------------------------------------------------------------------+ -contract TimelockGuard is IGuard, ISemver { +abstract contract TimelockGuard is IGuard { using EnumerableSet for EnumerableSet.Bytes32Set; /// @notice Allowed states of a transaction @@ -125,12 +122,6 @@ 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 - function version() public pure virtual returns (string memory) { - return "1.0.0"; - } - /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -213,7 +204,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Internal helper function which can be overriden in a child contract to check if the guard's /// configuration is valid in the context of other extensions that are enabled on the Safe. - function _checkCombinedConfig(Safe _safe) internal view virtual { } + function _checkCombinedConfig(Safe _safe) internal view virtual; //////////////////////////////////////////////////////////////// // External View Functions // diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index e552e6a00f23c..e690228be134d 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -6,6 +6,7 @@ import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { SaferSafes } from "src/safe/SaferSafes.sol"; /// @title LivenessModule2_TestInit /// @notice Reusable test initialization for `LivenessModule2` tests. @@ -24,7 +25,7 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { uint256 constant NUM_OWNERS = 5; uint256 constant THRESHOLD = 3; - LivenessModule2 livenessModule2; + SaferSafes livenessModule2; SafeInstance safeInstance; address fallbackOwner; address[] owners; @@ -33,8 +34,8 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { function setUp() public virtual { vm.warp(INIT_TIME); - // Deploy the singleton LivenessModule2 - livenessModule2 = new LivenessModule2(); + // Deploy the combined SaferSafes contract which implements LivenessModule2 + livenessModule2 = new SaferSafes(); // Create Safe owners (address[] memory _owners, uint256[] memory _keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index a0355a33112cf..f970328c7ac81 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -103,8 +103,8 @@ contract SaferSafes_Configure_Test is SaferSafes_TestInit { assertEq(saferSafes.timelockConfiguration(safeInstance.safe), timelockDelay); } - /// @notice Test that attempting to incorrectly configure the timelock guard after first configuring the liveness module - /// fails. + /// @notice Test that attempting to incorrectly configure the timelock guard after first configuring the liveness + /// module fails. /// @dev This test would fail if timelock guard configuration also triggered validation function test_configure_livenessModuleFirstInvalidConfig_reverts() public { uint256 timelockDelay = 7 days; diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 88e12657997d4..22473fccb2f1f 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -7,6 +7,7 @@ import { GuardManager } from "safe-contracts/base/GuardManager.sol"; import "test/safe-tools/SafeTestTools.sol"; import { TimelockGuard } from "src/safe/TimelockGuard.sol"; +import { SaferSafes } from "src/safe/SaferSafes.sol"; using TransactionBuilder for TransactionBuilder.Transaction; @@ -169,8 +170,10 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { function setUp() public virtual { vm.warp(INIT_TIME); - // Deploy the singleton TimelockGuard - timelockGuard = new TimelockGuard(); + // Deploy the combined SaferSafes contract which implements TimelockGuard + SaferSafes saferSafesImpl = new SaferSafes(); + timelockGuard = TimelockGuard(address(saferSafesImpl)); + // Set up Safe with owners safeInstance = _deploySafe("owners", NUM_OWNERS, THRESHOLD); safe = Safe(payable(safeInstance.safe)); From d35175cd0521d476396e79e94127055b2de53ae3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:36:18 -0400 Subject: [PATCH 06/90] fix test contract name --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index f970328c7ac81..5bf776036759c 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -49,9 +49,9 @@ contract SaferSafes_TestInit is Test, SafeTestTools { } } -/// @title SaferSafes_Configure_Test +/// @title SaferSafes_Uncategorized_Test /// @notice Tests for SaferSafes configuration functionality. -contract SaferSafes_Configure_Test is SaferSafes_TestInit { +contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { /// @notice Test successful configuration when liveness response period is at least 2x timelock delay. function test_configure_livenessModuleFirst_succeeds() public { uint256 timelockDelay = 7 days; From 3da8e5bf80e281153f23127cee9d54938f2796ef Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:02:50 -0400 Subject: [PATCH 07/90] Move semver to SaferSafes --- .../snapshots/semver-lock.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index c0eb064250f48..80b387864ca41 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -8,15 +8,15 @@ "sourceCodeHash": "0x6c9d3e2dee44c234d59ab93b6564536dfd807f1c4a02a82d5393bc53cb15b8b7" }, "src/L1/L1CrossDomainMessenger.sol:L1CrossDomainMessenger": { - "initCodeHash": "0x3dc659aafb03bd357f92abfc6794af89ee0ddd5212364551637422bf8d0b00f9", + "initCodeHash": "0xeac9c9fa1a18d7e8b897eb6501e64a079014754cfdc20d5a225909ccafe44185", "sourceCodeHash": "0xef3d366cd22eac2dfd22a658e003700c679bd9c38758d9c21befa7335bbd82ad" }, "src/L1/L1ERC721Bridge.sol:L1ERC721Bridge": { - "initCodeHash": "0x6f586bf82f6e89b75c2cc707e16a71ac921a911acf00f1594659f82e5c819fcc", + "initCodeHash": "0x843955ffe5f41d2714a0ea324e396e8df75f1ee7a05b839ae18fc70df883e704", "sourceCodeHash": "0x4d48a9cf80dd288d1c54c9576a1a8c12c1c5b9f1694246d0ebba60996f786b69" }, "src/L1/L1StandardBridge.sol:L1StandardBridge": { - "initCodeHash": "0xadd7863f0d14360be0f0c575d07aa304457b190b64a91a8976770fb7c34b28a3", + "initCodeHash": "0x9f015ee23e2ed76c3c855f78ab9b27a5716818530401dd7f51785d2b6a33b928", "sourceCodeHash": "0xfca613b5d055ffc4c3cbccb0773ddb9030abedc1aa6508c9e2e7727cc0cd617b" }, "src/L1/OPContractsManager.sol:OPContractsManager": { @@ -24,7 +24,7 @@ "sourceCodeHash": "0x154c764083f353e2a56337c0dd5cbcd6f2e12c21966cd0580c7a0f96c4e147dd" }, "src/L1/OPContractsManagerStandardValidator.sol:OPContractsManagerStandardValidator": { - "initCodeHash": "0x57d6a6729d887ead009d518e8f17fa0d26bfc97b8efe1494ab4ef8dbb000d109", + "initCodeHash": "0xf074a37724eb066169ff59e3f49dd10cc186e03f527fd791ec6055c9ca3f110e", "sourceCodeHash": "0x1d58891954cf782d2fe4f112b0c7fd25be991c2b8873f10d8545c653b517cac9" }, "src/L1/OptimismPortal2.sol:OptimismPortal2": { @@ -48,7 +48,7 @@ "sourceCodeHash": "0x006e3560f8b2e17133eb10a116916798ddc4345a7b006f8504dab69e810adb1c" }, "src/L2/BaseFeeVault.sol:BaseFeeVault": { - "initCodeHash": "0x9b664e3d84ad510091337b4aacaa494b142512e2f6f7fbcdb6210ed62ca9b885", + "initCodeHash": "0xef36f89bd6a5cc8a166ff8a59f09f4d5d28a2865d036917ef7474e8fcd6234c2", "sourceCodeHash": "0x508610081cade08f935e2a66f31cc193874bc0e2971a65db4a7842f1a428b1d0" }, "src/L2/CrossL2Inbox.sol:CrossL2Inbox": { @@ -68,7 +68,7 @@ "sourceCodeHash": "0x6e5349fd781d5f0127ff29ccea4d86a80240550cfa322364183a0f629abcb43e" }, "src/L2/L1FeeVault.sol:L1FeeVault": { - "initCodeHash": "0x9b664e3d84ad510091337b4aacaa494b142512e2f6f7fbcdb6210ed62ca9b885", + "initCodeHash": "0xef36f89bd6a5cc8a166ff8a59f09f4d5d28a2865d036917ef7474e8fcd6234c2", "sourceCodeHash": "0xfdf158752a5802c3697d6e8467046f6378680ceaa9e59ab02da0f5dcd575e5e2" }, "src/L2/L2CrossDomainMessenger.sol:L2CrossDomainMessenger": { @@ -92,11 +92,11 @@ "sourceCodeHash": "0x83396cbd12a0c5c02e09a4d99c4b62ab4e9d9eb762745e63283e2e818a78a39c" }, "src/L2/L2ToL2CrossDomainMessenger.sol:L2ToL2CrossDomainMessenger": { - "initCodeHash": "0x975fd33a3a386310d54dbb01b56f3a6a8350f55a3b6bd7781e5ccc2166ddf2e6", + "initCodeHash": "0x6f600a1bb7b76ea32a586c565623c9aed7368630a9ffe7003a4e314f2f0d10cc", "sourceCodeHash": "0xbea4229c5c6988243dbc7cf5a086ddd412fe1f2903b8e20d56699fec8de0c2c9" }, "src/L2/OperatorFeeVault.sol:OperatorFeeVault": { - "initCodeHash": "0x3d8c0d7736e8767f2f797da1c20c5fe30bd7f48a4cf75f376290481ad7c0f91f", + "initCodeHash": "0x729b8f0630dd1466169d13b0538c420e9158952a8d9a75e9a24500401bd92d70", "sourceCodeHash": "0x2022fdb4e32769eb9446dab4aed4b8abb5261fd866f381cccfa7869df1a2adff" }, "src/L2/OptimismMintableERC721.sol:OptimismMintableERC721": { @@ -112,7 +112,7 @@ "sourceCodeHash": "0xa135241cee15274eb07045674106becf8e830ddc55412ebf5d608c5c3da6313e" }, "src/L2/OptimismSuperchainERC20Beacon.sol:OptimismSuperchainERC20Beacon": { - "initCodeHash": "0x5d83dcdb91620e45fb32a32ad39ada98c5741e72d4ca668bb1a0933987098e59", + "initCodeHash": "0x76a6e0c942eb2c0b7766135fc25124b0f3dd34190c72957099a49a520896d4c9", "sourceCodeHash": "0x4582c8b84fbe62e5b754ebf9683ae31217af3567398f7d3da196addaf8de2045" }, "src/L2/OptimismSuperchainERC20Factory.sol:OptimismSuperchainERC20Factory": { @@ -128,19 +128,19 @@ "sourceCodeHash": "0x76eb2c7617e9b0b8dcd6b88470797cc946798a9ac067e3f195fff971fcabdbf5" }, "src/L2/SuperchainETHBridge.sol:SuperchainETHBridge": { - "initCodeHash": "0xa43665ad0f2b4f092ff04b12e38f24aa8d2cb34ae7a06fc037970743547bdf98", + "initCodeHash": "0xaa40f5233006a487b7d9bd4fe315c67d8dfd3f0eb7cc6743d31d91617f47d9e3", "sourceCodeHash": "0x862b8a2e5dd5cafcda55e35df7713b0d0b7a93d4d6ce29ea9ca53e045bf63cb4" }, "src/L2/SuperchainTokenBridge.sol:SuperchainTokenBridge": { - "initCodeHash": "0xb0d25dc03b9c84b07b263921c2b717e6caad3f4297fa939207e35978d7d25abe", + "initCodeHash": "0x6e68d77ba635e72b45acda17edede84f707f815f863fef38919fabd79d797c47", "sourceCodeHash": "0x0ff7c1f0264d784fac5d69b792c6bc9d064d4a09701c1bafa808388685c8c4f1" }, "src/L2/WETH.sol:WETH": { - "initCodeHash": "0xbc2cd025153720943e51b79822c2dc374d270a78b92cf47d49548c468e218e46", + "initCodeHash": "0x3e76cc196c03e62f39828f6ee139194f98c54c313702fe5b0418f691aa4204b8", "sourceCodeHash": "0x734a6b2aa6406bc145d848ad6071d3af1d40852aeb8f4b2f6f51beaad476e2d3" }, "src/cannon/MIPS64.sol:MIPS64": { - "initCodeHash": "0x6a649986370d18e5fddcd89df73e520063fb373f7dba2f731a2b7e79a1c132a5", + "initCodeHash": "0xb1b6312ca26f8a344d00e7710c7d6db5c757b41e8d517ebb0aaadf6954dfa4e9", "sourceCodeHash": "0x657afae82e6e3627389153736e568bf99498a272ec6d9ecc22ecfd645c56c453" }, "src/cannon/PreimageOracle.sol:PreimageOracle": { @@ -164,7 +164,7 @@ "sourceCodeHash": "0x63222e6926c8dd050d1adc0e65039c42382f269c3b0e113751d79e7a5167b7ac" }, "src/dispute/PermissionedDisputeGame.sol:PermissionedDisputeGame": { - "initCodeHash": "0xefa478f976e55eb53fcccf653b202bc2532781230f20013450ce0845b77d815c", + "initCodeHash": "0x99a037292c2b68c6b56264b3bc814c69b32f97c7e6fd2bce6a9a492f2312ad79", "sourceCodeHash": "0x335a503a4cc02dd30d88d163393680f3fd89168e0faa4fa4b0ae5da399656f91" }, "src/dispute/SuperFaultDisputeGame.sol:SuperFaultDisputeGame": { @@ -172,7 +172,7 @@ "sourceCodeHash": "0x089f457ecaa85379bcdb4b843a2b2db9616d87f957f7964de23f80e7655d3f53" }, "src/dispute/SuperPermissionedDisputeGame.sol:SuperPermissionedDisputeGame": { - "initCodeHash": "0x615baee73b605785025893fad655f8b7d8d546d77fbeca1f799000513ded3309", + "initCodeHash": "0x3520edada71d9bbea9bc241e7fc5543bb4870412175a1ffff36dde88e33afd3b", "sourceCodeHash": "0x8fdd69d4bcd33a3d8b49a73ff5b6855f9ad5f7e2b7393e67cd755973b127b1e8" }, "src/dispute/v2/FaultDisputeGameV2.sol:FaultDisputeGameV2": { @@ -184,7 +184,7 @@ "sourceCodeHash": "0x53fdae5faf97beed5f23d4f285bed06766161ab15c88e3a388f84808471a73c3" }, "src/legacy/DeployerWhitelist.sol:DeployerWhitelist": { - "initCodeHash": "0x2e0ef4c341367eb59cc6c25190c64eff441d3fe130189da91d4d126f6bdbc9b5", + "initCodeHash": "0x394647c31b522e78355833b227ed34744de4f5ffd8cce72564d97f546f9aae67", "sourceCodeHash": "0x99fb495ee1339f399d9e14cc56e4b3b128c67778ad9ca7bad1efbb49eda2ec4c" }, "src/legacy/L1BlockNumber.sol:L1BlockNumber": { @@ -192,7 +192,7 @@ "sourceCodeHash": "0xf4b4cae7cc81a93d192ce8c54a7b543327458d53f3aaababacea843825bf3e1c" }, "src/legacy/LegacyMessagePasser.sol:LegacyMessagePasser": { - "initCodeHash": "0x3a82e248129d19764bb975bb79b48a982f077f33bb508480bf8d2ec1c0c9810d", + "initCodeHash": "0x86b267c47650533bb13670d5f88b06a684432b4127197623f93cd99c103fa54c", "sourceCodeHash": "0x955bd0c9b47e43219865e4e92abf28d916c96de20cbdf2f94c8ab14d02083759" }, "src/safe/DeputyPauseModule.sol:DeputyPauseModule": { @@ -212,7 +212,7 @@ "sourceCodeHash": "0xe3d2fd50724baf6d5e83e2c9d89fcfcbc678d966187bd38e2d9a840716bcafa3" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { - "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", + "initCodeHash": "0x1da1f55a09797d7babef9cc86f7a383099357cc53d84dcb2306596862f2abf72", "sourceCodeHash": "0x7023665d461f173417d932b55010b8f6c34f2bbaf56cfe4e1b15862c08cbcaac" }, "src/universal/OptimismMintableERC20Factory.sol:OptimismMintableERC20Factory": { From 64e0153ecea6d4f4e39995b43f3c80bbccf065ee Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:13:13 -0400 Subject: [PATCH 08/90] Disable the guard and module upon ownership transfer --- .../src/safe/LivenessModule2.sol | 47 +++++++++++++++++++ .../test/safe/LivenessModule2.t.sol | 30 +++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index ac9fe40839ee6..545be4858b8f9 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.15; import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; +import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner @@ -274,6 +276,17 @@ abstract contract LivenessModule2 { ) }); + // Deactivate the guard + targetSafe.execTransactionFromModule({ + to: address(targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); + + // Disable this module from the Safe + _disableThisModule(targetSafe); + // Sanity check: verify the fallback owner is now the only owner address[] memory finalOwners = targetSafe.getOwners(); if (finalOwners.length != 1 || finalOwners[0] != livenessSafeConfiguration[_safe].fallbackOwner) { @@ -322,4 +335,38 @@ abstract contract LivenessModule2 { delete challengeStartTime[_safe]; emit ChallengeCancelled(_safe); } + + /// @notice Internal function to disable this module from the given Safe. + /// @param _targetSafe The Safe instance to disable this module from. + function _disableThisModule(Safe _targetSafe) internal { + // Get current modules + // This might not work if you have more than 100 modules, but that's a you problem. + (address[] memory modules, ) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); + + // find the index of this module + bool moduleFound = false; + uint256 moduleIndex = 0; + for (uint256 i = 0; i < modules.length; i++) { + if (modules[i] == address(this)) { + moduleIndex = i; + moduleFound = true; + break; + } + } + if (moduleFound) { + // If the module is the first in the list, then the previous module is the sentinel. + address prevModule = SENTINEL_OWNER; + // If the module is not the first in the list, then the previous module is the module before in in the array. + if (moduleIndex > 0) { + prevModule = modules[moduleIndex - 1]; + } + // Disable the module + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(ModuleManager.disableModule, (prevModule, address(this))) + }); + } + } } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index e690228be134d..9f00b17478b70 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -92,6 +92,13 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call ); } + + function _getGuard(SafeInstance memory _safe) internal view returns (address) { + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_safe.safe.getStorageAt(uint256(guardSlot), 1), (address)); + return guard; + } } /// @title LivenessModule2_Configure_Test @@ -413,7 +420,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(configuredSafe.safe)); assertTrue(period > 0); // Configuration exists assertTrue(fbOwner != address(0)); // Configuration exists - assertFalse(configuredSafe.safe.isModuleEnabled(address(livenessModule2))); // Module not enabled + assertFalse(ModuleManager(configuredSafe.safe).isModuleEnabled(address(livenessModule2))); // Module not enabled // Now respond() should revert because module is not enabled vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); @@ -427,7 +434,18 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestInit { function setUp() public override { super.setUp(); + // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle + // multiple modules and only remove the correct one. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Enable a few more modules after LivenessModule2. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); } function test_changeOwnershipToFallback_succeeds() external { @@ -454,6 +472,16 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Verify challenge is reset uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertEq(challengeEndTime, 0); + + // Verify module is disabled + assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); + + // Verify guard is deactivated + assertEq(_getGuard(safeInstance), address(0)); + + // Verify extra modules are still enabled + (address[] memory modules, ) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); + assertEq(modules.length, 6); } function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { From 300ab95f38ef72376f78cf68378010676590c62e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:26:06 -0400 Subject: [PATCH 09/90] Add _disableThisGuard function --- .../src/safe/LivenessModule2.sol | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 545be4858b8f9..9b34be002b5f3 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -276,13 +276,8 @@ abstract contract LivenessModule2 { ) }); - // Deactivate the guard - targetSafe.execTransactionFromModule({ - to: address(targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); + // Disable this guard from the Safe (if and only if it is enabled). + _disableThisGuard(targetSafe); // Disable this module from the Safe _disableThisModule(targetSafe); @@ -336,6 +331,24 @@ abstract contract LivenessModule2 { emit ChallengeCancelled(_safe); } + /// @notice Internal function to disable this guard from the given Safe. + /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another + /// guard is enabled. + /// @param _targetSafe The Safe instance to disable this guard from. + function _disableThisGuard(Safe _targetSafe) internal { + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); + if (guard == address(this)) { + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); + } + } + /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { From 5efbf2a7e6c07b0471a21a69b535e77c9cd08963 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:26:37 -0400 Subject: [PATCH 10/90] Update tests --- .../test/safe/LivenessModule2.t.sol | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 9f00b17478b70..1aec01a1cd44e 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -484,6 +484,28 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(modules.length, 6); } + function test_changeOwnershipToFallback_doesNotRemoveOtherGuard_succeeds() external { + // Enable a guard on the Safe + SafeTestLib.setGuard(safeInstance, address(makeAddr("guard"))); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify guard is still enabled + assertEq(_getGuard(safeInstance), address(makeAddr("guard"))); + } + function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); @@ -555,23 +577,6 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(newOwners.length, 1); assertEq(newOwners[0], fallbackOwner); } - - function test_changeOwnershipToFallback_canRechallenge_succeeds() external { - // Start and execute first challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Start a new challenge (as fallback owner) - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); - assertGt(challengeEndTime, 0); - } } /// @title LivenessModule2_GetChallengePeriodEnd_Test From 6e2acada4e3beda08b3498c3ecac809bb8a0b404 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:33:11 -0400 Subject: [PATCH 11/90] Add config resets --- .../src/safe/LivenessModule2.sol | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 9b34be002b5f3..fc122b6693e1a 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -7,6 +7,7 @@ import { Enum } from "safe-contracts/common/Enum.sol"; import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { TimelockGuard } from "src/safe/TimelockGuard.sol"; /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner @@ -336,6 +337,15 @@ abstract contract LivenessModule2 { /// guard is enabled. /// @param _targetSafe The Safe instance to disable this guard from. function _disableThisGuard(Safe _targetSafe) internal { + // set the timelock delay to 0 to clear the configuration + _targetSafe.execTransactionFromModule({ + to: address(this), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(TimelockGuard.configureTimelockGuard, (0)) + }); + + // Check if the guard is enabled // keccak256("guard_manager.guard.address") from GuardManager bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); @@ -352,6 +362,14 @@ abstract contract LivenessModule2 { /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { + // Clear the module configuration + _targetSafe.execTransactionFromModule({ + to: address(this), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(LivenessModule2.clearLivenessModule, ()) + }); + // Get current modules // This might not work if you have more than 100 modules, but that's a you problem. (address[] memory modules, ) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); From 8f161cfffcdd450ec78c086310500d673d3b59e9 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:43:53 -0400 Subject: [PATCH 12/90] fmt --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index fc122b6693e1a..80bcc9b74d776 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -372,7 +372,7 @@ abstract contract LivenessModule2 { // Get current modules // This might not work if you have more than 100 modules, but that's a you problem. - (address[] memory modules, ) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); + (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); // find the index of this module bool moduleFound = false; @@ -387,7 +387,8 @@ abstract contract LivenessModule2 { if (moduleFound) { // If the module is the first in the list, then the previous module is the sentinel. address prevModule = SENTINEL_OWNER; - // If the module is not the first in the list, then the previous module is the module before in in the array. + // If the module is not the first in the list, then the previous module is the module before in in the + // array. if (moduleIndex > 0) { prevModule = modules[moduleIndex - 1]; } From b5461ff8b79bba0868fca3976c8fcc8e79d47c79 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:44:08 -0400 Subject: [PATCH 13/90] fix test_changeOwnershipToFallback_canRechallenge_succeeds --- .../test/safe/LivenessModule2.t.sol | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 1aec01a1cd44e..44b4ae27445a6 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -480,7 +480,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(_getGuard(safeInstance), address(0)); // Verify extra modules are still enabled - (address[] memory modules, ) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); + (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); assertEq(modules.length, 6); } @@ -577,6 +577,44 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(newOwners.length, 1); assertEq(newOwners[0], fallbackOwner); } + + function test_changeOwnershipToFallback_canRechallenge_succeeds() external { + // Start and execute first challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Re-enable the module + vm.prank(fallbackOwner); + safeInstance.safe.execTransaction( + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.enableModule, (address(livenessModule2))), + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(address(0)), + abi.encodePacked(bytes32(uint256(uint160(fallbackOwner))), bytes32(0), uint8(1)) + ); + + // Re-configure the module + vm.prank(address(safeInstance.safe)); + livenessModule2.configureLivenessModule( + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) + ); + + // Start a new challenge (as fallback owner) + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertGt(challengeEndTime, 0); + } } /// @title LivenessModule2_GetChallengePeriodEnd_Test From 46fb1558afe1a726a5abec04ccf17f1ab3cbf8a7 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 15:52:15 -0400 Subject: [PATCH 14/90] Simplify by clearing config directly --- .../contracts-bedrock/src/safe/LivenessModule2.sol | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 80bcc9b74d776..52361df9432c0 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -363,18 +363,14 @@ abstract contract LivenessModule2 { /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { // Clear the module configuration - _targetSafe.execTransactionFromModule({ - to: address(this), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(LivenessModule2.clearLivenessModule, ()) - }); + // Erase the configuration data for this safe + delete livenessSafeConfiguration[address(_targetSafe)]; // Get current modules - // This might not work if you have more than 100 modules, but that's a you problem. + // This might not work if you have more than 100 modules, but what are you even doing if that's the case? (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); - // find the index of this module + // Find the index of this module bool moduleFound = false; uint256 moduleIndex = 0; for (uint256 i = 0; i < modules.length; i++) { From 993232aa386c64b5ae623320d9537e4af7562a3f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:21:01 -0400 Subject: [PATCH 15/90] Put _disableThisGuard into child contract --- .../src/safe/LivenessModule2.sol | 23 +---------------- .../contracts-bedrock/src/safe/SaferSafes.sol | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 52361df9432c0..d003bc2386e94 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -336,28 +336,7 @@ abstract contract LivenessModule2 { /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another /// guard is enabled. /// @param _targetSafe The Safe instance to disable this guard from. - function _disableThisGuard(Safe _targetSafe) internal { - // set the timelock delay to 0 to clear the configuration - _targetSafe.execTransactionFromModule({ - to: address(this), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(TimelockGuard.configureTimelockGuard, (0)) - }); - - // Check if the guard is enabled - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); - if (guard == address(this)) { - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); - } - } + function _disableThisGuard(Safe _targetSafe) internal virtual; /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index e468652678a33..4d88a2bd8a1d5 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -3,6 +3,8 @@ pragma solidity 0.8.15; // Safe import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; // Safe Extensions import { LivenessModule2 } from "./LivenessModule2.sol"; @@ -60,4 +62,27 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { revert SaferSafes_InsufficientLivenessResponsePeriod(); } } + + /// @notice Internal function to disable this guard from the given Safe. + /// @param _targetSafe The Safe instance to disable this guard from. + function _disableThisGuard(Safe _targetSafe) internal override { + // set the timelock delay to 0 to clear the configuration + SafeState storage safeState = _safeState[_targetSafe]; + safeState.cancellationThreshold = 1; + + // Get the address of the current guard + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); + + // If the current guard is this guard, disable it + if (guard == address(this)) { + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); + } + } } From 7f1d9eff34a3b531e53145109e5d33d7f5e53d94 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:34:25 -0400 Subject: [PATCH 16/90] Add timelockDelay reset on _disableThisGuard --- packages/contracts-bedrock/src/safe/SaferSafes.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 4d88a2bd8a1d5..c8c547a6a97c4 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -66,8 +66,11 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Internal function to disable this guard from the given Safe. /// @param _targetSafe The Safe instance to disable this guard from. function _disableThisGuard(Safe _targetSafe) internal override { - // set the timelock delay to 0 to clear the configuration SafeState storage safeState = _safeState[_targetSafe]; + // set the timelock delay to 0 to clear the configuration + safeState.timelockDelay = 0; + + // Reset the cancellation threshold, 1 is the default value for all safes. safeState.cancellationThreshold = 1; // Get the address of the current guard From aa50113fe6c66434c78829d539f51925b112c951 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:39:33 -0400 Subject: [PATCH 17/90] semver-lock --- .../snapshots/semver-lock.json | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 80b387864ca41..8cea8c0162fc9 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -8,15 +8,15 @@ "sourceCodeHash": "0x6c9d3e2dee44c234d59ab93b6564536dfd807f1c4a02a82d5393bc53cb15b8b7" }, "src/L1/L1CrossDomainMessenger.sol:L1CrossDomainMessenger": { - "initCodeHash": "0xeac9c9fa1a18d7e8b897eb6501e64a079014754cfdc20d5a225909ccafe44185", + "initCodeHash": "0x3dc659aafb03bd357f92abfc6794af89ee0ddd5212364551637422bf8d0b00f9", "sourceCodeHash": "0xef3d366cd22eac2dfd22a658e003700c679bd9c38758d9c21befa7335bbd82ad" }, "src/L1/L1ERC721Bridge.sol:L1ERC721Bridge": { - "initCodeHash": "0x843955ffe5f41d2714a0ea324e396e8df75f1ee7a05b839ae18fc70df883e704", + "initCodeHash": "0x6f586bf82f6e89b75c2cc707e16a71ac921a911acf00f1594659f82e5c819fcc", "sourceCodeHash": "0x4d48a9cf80dd288d1c54c9576a1a8c12c1c5b9f1694246d0ebba60996f786b69" }, "src/L1/L1StandardBridge.sol:L1StandardBridge": { - "initCodeHash": "0x9f015ee23e2ed76c3c855f78ab9b27a5716818530401dd7f51785d2b6a33b928", + "initCodeHash": "0xadd7863f0d14360be0f0c575d07aa304457b190b64a91a8976770fb7c34b28a3", "sourceCodeHash": "0xfca613b5d055ffc4c3cbccb0773ddb9030abedc1aa6508c9e2e7727cc0cd617b" }, "src/L1/OPContractsManager.sol:OPContractsManager": { @@ -24,7 +24,7 @@ "sourceCodeHash": "0x154c764083f353e2a56337c0dd5cbcd6f2e12c21966cd0580c7a0f96c4e147dd" }, "src/L1/OPContractsManagerStandardValidator.sol:OPContractsManagerStandardValidator": { - "initCodeHash": "0xf074a37724eb066169ff59e3f49dd10cc186e03f527fd791ec6055c9ca3f110e", + "initCodeHash": "0x57d6a6729d887ead009d518e8f17fa0d26bfc97b8efe1494ab4ef8dbb000d109", "sourceCodeHash": "0x1d58891954cf782d2fe4f112b0c7fd25be991c2b8873f10d8545c653b517cac9" }, "src/L1/OptimismPortal2.sol:OptimismPortal2": { @@ -48,7 +48,7 @@ "sourceCodeHash": "0x006e3560f8b2e17133eb10a116916798ddc4345a7b006f8504dab69e810adb1c" }, "src/L2/BaseFeeVault.sol:BaseFeeVault": { - "initCodeHash": "0xef36f89bd6a5cc8a166ff8a59f09f4d5d28a2865d036917ef7474e8fcd6234c2", + "initCodeHash": "0x9b664e3d84ad510091337b4aacaa494b142512e2f6f7fbcdb6210ed62ca9b885", "sourceCodeHash": "0x508610081cade08f935e2a66f31cc193874bc0e2971a65db4a7842f1a428b1d0" }, "src/L2/CrossL2Inbox.sol:CrossL2Inbox": { @@ -68,7 +68,7 @@ "sourceCodeHash": "0x6e5349fd781d5f0127ff29ccea4d86a80240550cfa322364183a0f629abcb43e" }, "src/L2/L1FeeVault.sol:L1FeeVault": { - "initCodeHash": "0xef36f89bd6a5cc8a166ff8a59f09f4d5d28a2865d036917ef7474e8fcd6234c2", + "initCodeHash": "0x9b664e3d84ad510091337b4aacaa494b142512e2f6f7fbcdb6210ed62ca9b885", "sourceCodeHash": "0xfdf158752a5802c3697d6e8467046f6378680ceaa9e59ab02da0f5dcd575e5e2" }, "src/L2/L2CrossDomainMessenger.sol:L2CrossDomainMessenger": { @@ -92,11 +92,11 @@ "sourceCodeHash": "0x83396cbd12a0c5c02e09a4d99c4b62ab4e9d9eb762745e63283e2e818a78a39c" }, "src/L2/L2ToL2CrossDomainMessenger.sol:L2ToL2CrossDomainMessenger": { - "initCodeHash": "0x6f600a1bb7b76ea32a586c565623c9aed7368630a9ffe7003a4e314f2f0d10cc", + "initCodeHash": "0x975fd33a3a386310d54dbb01b56f3a6a8350f55a3b6bd7781e5ccc2166ddf2e6", "sourceCodeHash": "0xbea4229c5c6988243dbc7cf5a086ddd412fe1f2903b8e20d56699fec8de0c2c9" }, "src/L2/OperatorFeeVault.sol:OperatorFeeVault": { - "initCodeHash": "0x729b8f0630dd1466169d13b0538c420e9158952a8d9a75e9a24500401bd92d70", + "initCodeHash": "0x3d8c0d7736e8767f2f797da1c20c5fe30bd7f48a4cf75f376290481ad7c0f91f", "sourceCodeHash": "0x2022fdb4e32769eb9446dab4aed4b8abb5261fd866f381cccfa7869df1a2adff" }, "src/L2/OptimismMintableERC721.sol:OptimismMintableERC721": { @@ -112,7 +112,7 @@ "sourceCodeHash": "0xa135241cee15274eb07045674106becf8e830ddc55412ebf5d608c5c3da6313e" }, "src/L2/OptimismSuperchainERC20Beacon.sol:OptimismSuperchainERC20Beacon": { - "initCodeHash": "0x76a6e0c942eb2c0b7766135fc25124b0f3dd34190c72957099a49a520896d4c9", + "initCodeHash": "0x5d83dcdb91620e45fb32a32ad39ada98c5741e72d4ca668bb1a0933987098e59", "sourceCodeHash": "0x4582c8b84fbe62e5b754ebf9683ae31217af3567398f7d3da196addaf8de2045" }, "src/L2/OptimismSuperchainERC20Factory.sol:OptimismSuperchainERC20Factory": { @@ -128,19 +128,19 @@ "sourceCodeHash": "0x76eb2c7617e9b0b8dcd6b88470797cc946798a9ac067e3f195fff971fcabdbf5" }, "src/L2/SuperchainETHBridge.sol:SuperchainETHBridge": { - "initCodeHash": "0xaa40f5233006a487b7d9bd4fe315c67d8dfd3f0eb7cc6743d31d91617f47d9e3", + "initCodeHash": "0xa43665ad0f2b4f092ff04b12e38f24aa8d2cb34ae7a06fc037970743547bdf98", "sourceCodeHash": "0x862b8a2e5dd5cafcda55e35df7713b0d0b7a93d4d6ce29ea9ca53e045bf63cb4" }, "src/L2/SuperchainTokenBridge.sol:SuperchainTokenBridge": { - "initCodeHash": "0x6e68d77ba635e72b45acda17edede84f707f815f863fef38919fabd79d797c47", + "initCodeHash": "0xb0d25dc03b9c84b07b263921c2b717e6caad3f4297fa939207e35978d7d25abe", "sourceCodeHash": "0x0ff7c1f0264d784fac5d69b792c6bc9d064d4a09701c1bafa808388685c8c4f1" }, "src/L2/WETH.sol:WETH": { - "initCodeHash": "0x3e76cc196c03e62f39828f6ee139194f98c54c313702fe5b0418f691aa4204b8", + "initCodeHash": "0xbc2cd025153720943e51b79822c2dc374d270a78b92cf47d49548c468e218e46", "sourceCodeHash": "0x734a6b2aa6406bc145d848ad6071d3af1d40852aeb8f4b2f6f51beaad476e2d3" }, "src/cannon/MIPS64.sol:MIPS64": { - "initCodeHash": "0xb1b6312ca26f8a344d00e7710c7d6db5c757b41e8d517ebb0aaadf6954dfa4e9", + "initCodeHash": "0x6a649986370d18e5fddcd89df73e520063fb373f7dba2f731a2b7e79a1c132a5", "sourceCodeHash": "0x657afae82e6e3627389153736e568bf99498a272ec6d9ecc22ecfd645c56c453" }, "src/cannon/PreimageOracle.sol:PreimageOracle": { @@ -164,7 +164,7 @@ "sourceCodeHash": "0x63222e6926c8dd050d1adc0e65039c42382f269c3b0e113751d79e7a5167b7ac" }, "src/dispute/PermissionedDisputeGame.sol:PermissionedDisputeGame": { - "initCodeHash": "0x99a037292c2b68c6b56264b3bc814c69b32f97c7e6fd2bce6a9a492f2312ad79", + "initCodeHash": "0xefa478f976e55eb53fcccf653b202bc2532781230f20013450ce0845b77d815c", "sourceCodeHash": "0x335a503a4cc02dd30d88d163393680f3fd89168e0faa4fa4b0ae5da399656f91" }, "src/dispute/SuperFaultDisputeGame.sol:SuperFaultDisputeGame": { @@ -172,7 +172,7 @@ "sourceCodeHash": "0x089f457ecaa85379bcdb4b843a2b2db9616d87f957f7964de23f80e7655d3f53" }, "src/dispute/SuperPermissionedDisputeGame.sol:SuperPermissionedDisputeGame": { - "initCodeHash": "0x3520edada71d9bbea9bc241e7fc5543bb4870412175a1ffff36dde88e33afd3b", + "initCodeHash": "0x615baee73b605785025893fad655f8b7d8d546d77fbeca1f799000513ded3309", "sourceCodeHash": "0x8fdd69d4bcd33a3d8b49a73ff5b6855f9ad5f7e2b7393e67cd755973b127b1e8" }, "src/dispute/v2/FaultDisputeGameV2.sol:FaultDisputeGameV2": { @@ -184,7 +184,7 @@ "sourceCodeHash": "0x53fdae5faf97beed5f23d4f285bed06766161ab15c88e3a388f84808471a73c3" }, "src/legacy/DeployerWhitelist.sol:DeployerWhitelist": { - "initCodeHash": "0x394647c31b522e78355833b227ed34744de4f5ffd8cce72564d97f546f9aae67", + "initCodeHash": "0x2e0ef4c341367eb59cc6c25190c64eff441d3fe130189da91d4d126f6bdbc9b5", "sourceCodeHash": "0x99fb495ee1339f399d9e14cc56e4b3b128c67778ad9ca7bad1efbb49eda2ec4c" }, "src/legacy/L1BlockNumber.sol:L1BlockNumber": { @@ -192,7 +192,7 @@ "sourceCodeHash": "0xf4b4cae7cc81a93d192ce8c54a7b543327458d53f3aaababacea843825bf3e1c" }, "src/legacy/LegacyMessagePasser.sol:LegacyMessagePasser": { - "initCodeHash": "0x86b267c47650533bb13670d5f88b06a684432b4127197623f93cd99c103fa54c", + "initCodeHash": "0x3a82e248129d19764bb975bb79b48a982f077f33bb508480bf8d2ec1c0c9810d", "sourceCodeHash": "0x955bd0c9b47e43219865e4e92abf28d916c96de20cbdf2f94c8ab14d02083759" }, "src/safe/DeputyPauseModule.sol:DeputyPauseModule": { @@ -208,11 +208,11 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x22730f6ebe10e0d78b23b3e0f3d58f867f420039981455b09a508755e512c127", - "sourceCodeHash": "0xe3d2fd50724baf6d5e83e2c9d89fcfcbc678d966187bd38e2d9a840716bcafa3" + "initCodeHash": "0x92a1f10931394c260f7d4e6ccf382f452108b057b5d411e4179c70a83fa6bb29", + "sourceCodeHash": "0xdd8d4bc99d385a763009788da5697c31c605a47615741d724f0f5f0640eaa1cb" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { - "initCodeHash": "0x1da1f55a09797d7babef9cc86f7a383099357cc53d84dcb2306596862f2abf72", + "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", "sourceCodeHash": "0x7023665d461f173417d932b55010b8f6c34f2bbaf56cfe4e1b15862c08cbcaac" }, "src/universal/OptimismMintableERC20Factory.sol:OptimismMintableERC20Factory": { From 6cb51671d9631ed18c94319f83bad8a76f7230e5 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:45:56 -0400 Subject: [PATCH 18/90] Move _disableThisGuard logic into TimelockGuard --- .../contracts-bedrock/src/safe/SaferSafes.sol | 24 ++------------- .../src/safe/TimelockGuard.sol | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index c8c547a6a97c4..cfbb63e9260b1 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -65,27 +65,7 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Internal function to disable this guard from the given Safe. /// @param _targetSafe The Safe instance to disable this guard from. - function _disableThisGuard(Safe _targetSafe) internal override { - SafeState storage safeState = _safeState[_targetSafe]; - // set the timelock delay to 0 to clear the configuration - safeState.timelockDelay = 0; - - // Reset the cancellation threshold, 1 is the default value for all safes. - safeState.cancellationThreshold = 1; - - // Get the address of the current guard - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); - - // If the current guard is this guard, disable it - if (guard == address(this)) { - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); - } + function _disableThisGuard(Safe _targetSafe) internal override(TimelockGuard, LivenessModule2) { + TimelockGuard._disableThisGuard(_targetSafe); } } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 183b2a46c8e70..3c7caf5b659f6 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 { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { Guard as IGuard, GuardManager } from "safe-contracts/base/GuardManager.sol"; // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; @@ -612,4 +612,31 @@ abstract contract TimelockGuard is IGuard { function signCancellation(bytes32) public { emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } + + /// @notice Internal function to disable this guard from the given Safe. + /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. + /// @param _targetSafe The Safe instance to disable this guard from. + function _disableThisGuard(Safe _targetSafe) internal virtual { + SafeState storage safeState = _safeState[_targetSafe]; + // set the timelock delay to 0 to clear the configuration + safeState.timelockDelay = 0; + + // Reset the cancellation threshold, 1 is the default value for all safes. + safeState.cancellationThreshold = 1; + + // Get the address of the current guard + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); + + // If the current guard is this guard, disable it + if (guard == address(this)) { + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); + } + } } From 74113a76c352d49c471a47edc4428a8f88a05d25 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:47:45 -0400 Subject: [PATCH 19/90] clear livenessSafeConfig at tend of _disableThisModule --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index d003bc2386e94..e1bbdbe0aa98c 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -341,10 +341,6 @@ abstract contract LivenessModule2 { /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { - // Clear the module configuration - // Erase the configuration data for this safe - delete livenessSafeConfiguration[address(_targetSafe)]; - // Get current modules // This might not work if you have more than 100 modules, but what are you even doing if that's the case? (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); @@ -375,5 +371,9 @@ abstract contract LivenessModule2 { data: abi.encodeCall(ModuleManager.disableModule, (prevModule, address(this))) }); } + + // Clear the module configuration + // Erase the configuration data for this safe + delete livenessSafeConfiguration[address(_targetSafe)]; } } From 7242c80a63fcfdf597dfe47ed3a36e287104f33f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 9 Oct 2025 16:49:11 -0400 Subject: [PATCH 20/90] Clarify use of SENTINEL_OWNER --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index e1bbdbe0aa98c..efffd4898c9e7 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -342,6 +342,8 @@ abstract contract LivenessModule2 { /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { // Get current modules + // We use SENTINEL_OWNER because it's already defined in this contract, and has the same value + // as the SENTINEL_MODULES address used in ModuleManager. // This might not work if you have more than 100 modules, but what are you even doing if that's the case? (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); From cfb61109a27c1bcb2e295b3af9a86418caa0579e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 10 Oct 2025 08:55:06 -0400 Subject: [PATCH 21/90] Fix the ordering of the disableGuard and disableModule calls --- .../src/safe/LivenessModule2.sol | 16 +++--- .../contracts-bedrock/src/safe/SaferSafes.sol | 26 ++++++++-- .../src/safe/TimelockGuard.sol | 27 ---------- .../test/safe/LivenessModule2.t.sol | 50 ++++++++++++------- 4 files changed, 63 insertions(+), 56 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index efffd4898c9e7..e52e609bab71d 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -277,12 +277,6 @@ abstract contract LivenessModule2 { ) }); - // Disable this guard from the Safe (if and only if it is enabled). - _disableThisGuard(targetSafe); - - // Disable this module from the Safe - _disableThisModule(targetSafe); - // Sanity check: verify the fallback owner is now the only owner address[] memory finalOwners = targetSafe.getOwners(); if (finalOwners.length != 1 || finalOwners[0] != livenessSafeConfiguration[_safe].fallbackOwner) { @@ -291,8 +285,16 @@ abstract contract LivenessModule2 { // Reset the challenge state to allow a new challenge delete challengeStartTime[_safe]; - emit ChallengeSucceeded(_safe, livenessSafeConfiguration[_safe].fallbackOwner); + + // Now we will disable the guard and module from the Safe, so that the Safe is set to a + // minimal state, which is as simple as possible for the fallback owner to reason about. + + // Disable this guard from the Safe (if and only if it is enabled). + _disableThisGuard(targetSafe); + + // Disable this module from the Safe + _disableThisModule(targetSafe); } /// @notice Asserts that the module is configured for the given Safe. diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index cfbb63e9260b1..c7f824958f105 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -63,9 +63,27 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { } } - /// @notice Internal function to disable this guard from the given Safe. - /// @param _targetSafe The Safe instance to disable this guard from. - function _disableThisGuard(Safe _targetSafe) internal override(TimelockGuard, LivenessModule2) { - TimelockGuard._disableThisGuard(_targetSafe); + /// @notice Internal function to disable the guard from the given Safe. + /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. + /// @param _targetSafe The Safe instance to disable the guard from. + function _disableThisGuard(Safe _targetSafe) internal override { + SafeState storage safeState = _safeState[_targetSafe]; + // set the timelock delay to 0 to clear the configuration + safeState.timelockDelay = 0; + + // Reset the cancellation threshold, 1 is the default value for all safes. + safeState.cancellationThreshold = 1; + + // Disable the guard + // Note that this will remove whichever guard is currently set on the Safe, + // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard + // itself was the cause of the liveness failure which resulted in the transfer of ownership to + // the fallback owner. + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); } } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 3c7caf5b659f6..bf61d4b790169 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -612,31 +612,4 @@ abstract contract TimelockGuard is IGuard { function signCancellation(bytes32) public { emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } - - /// @notice Internal function to disable this guard from the given Safe. - /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. - /// @param _targetSafe The Safe instance to disable this guard from. - function _disableThisGuard(Safe _targetSafe) internal virtual { - SafeState storage safeState = _safeState[_targetSafe]; - // set the timelock delay to 0 to clear the configuration - safeState.timelockDelay = 0; - - // Reset the cancellation threshold, 1 is the default value for all safes. - safeState.cancellationThreshold = 1; - - // Get the address of the current guard - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(_targetSafe.getStorageAt(uint256(guardSlot), 1), (address)); - - // If the current guard is this guard, disable it - if (guard == address(this)) { - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); - } - } } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 44b4ae27445a6..944fbe15837a3 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -434,18 +434,17 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestInit { function setUp() public override { super.setUp(); - // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle - // multiple modules and only remove the correct one. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - // Enable a few more modules after LivenessModule2. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); + // enable the guard + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (address(livenessModule2))), + Enum.Operation.Call + ); } function test_changeOwnershipToFallback_succeeds() external { @@ -478,15 +477,27 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Verify guard is deactivated assertEq(_getGuard(safeInstance), address(0)); - - // Verify extra modules are still enabled - (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); - assertEq(modules.length, 6); } - function test_changeOwnershipToFallback_doesNotRemoveOtherGuard_succeeds() external { - // Enable a guard on the Safe - SafeTestLib.setGuard(safeInstance, address(makeAddr("guard"))); + function test_changeOwnershipToFallback_withOtherModules_succeeds() external { + // First disable the module, because we want it to be in the middle of the Safe's list + // of modules. + _disableModule(safeInstance); + + // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle + // multiple modules and only remove the correct one. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); + + // Enable the LivenessModule2 on the Safe + SafeTestLib.enableModule(safeInstance, address(livenessModule2)); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Enable a few more modules after LivenessModule2. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); // Start a challenge vm.prank(fallbackOwner); @@ -502,8 +513,11 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI vm.prank(fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - // Verify guard is still enabled - assertEq(_getGuard(safeInstance), address(makeAddr("guard"))); + // Verify module is disabled + assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); + // Verify extra modules are still enabled + (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); + assertEq(modules.length, 6); } function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { From 3b1132b1094f4abad3af64508d02f9b9d2076b84 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 10 Oct 2025 09:00:51 -0400 Subject: [PATCH 22/90] 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 8cea8c0162fc9..11b82e8d38073 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x92a1f10931394c260f7d4e6ccf382f452108b057b5d411e4179c70a83fa6bb29", - "sourceCodeHash": "0xdd8d4bc99d385a763009788da5697c31c605a47615741d724f0f5f0640eaa1cb" + "initCodeHash": "0xee8e43aed70900129c457171d570facfc66548572316faf911105ccee9c54667", + "sourceCodeHash": "0xcfdcea487369fc0033db5262ed8c3d09b7489fa02c9db3753c8f7dd4b412bab7" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", From 48663a6f80ed2616a9b0230561723892651925ed Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 10 Oct 2025 09:16:06 -0400 Subject: [PATCH 23/90] remove unused imports --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 2 -- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index e52e609bab71d..1a58963465937 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -6,8 +6,6 @@ import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; -import { GuardManager } from "safe-contracts/base/GuardManager.sol"; -import { TimelockGuard } from "src/safe/TimelockGuard.sol"; /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index bf61d4b790169..183b2a46c8e70 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 { Guard as IGuard, GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; From c6ea9139620dad359bccc9a2c96ce6bc25c63054 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 10 Oct 2025 09:20:25 -0400 Subject: [PATCH 24/90] rename _disableThisGuard to _disableGuard --- packages/contracts-bedrock/snapshots/semver-lock.json | 2 +- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 4 ++-- packages/contracts-bedrock/src/safe/SaferSafes.sol | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 11b82e8d38073..d9e5a831dd474 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -209,7 +209,7 @@ }, "src/safe/SaferSafes.sol:SaferSafes": { "initCodeHash": "0xee8e43aed70900129c457171d570facfc66548572316faf911105ccee9c54667", - "sourceCodeHash": "0xcfdcea487369fc0033db5262ed8c3d09b7489fa02c9db3753c8f7dd4b412bab7" + "sourceCodeHash": "0xb7a3cbee00174527e95d95835573d6846a09fb7df7b04d144ac59ec4715e978f" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 1a58963465937..3a8513720e2e3 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -289,7 +289,7 @@ abstract contract LivenessModule2 { // minimal state, which is as simple as possible for the fallback owner to reason about. // Disable this guard from the Safe (if and only if it is enabled). - _disableThisGuard(targetSafe); + _disableGuard(targetSafe); // Disable this module from the Safe _disableThisModule(targetSafe); @@ -336,7 +336,7 @@ abstract contract LivenessModule2 { /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another /// guard is enabled. /// @param _targetSafe The Safe instance to disable this guard from. - function _disableThisGuard(Safe _targetSafe) internal virtual; + function _disableGuard(Safe _targetSafe) internal virtual; /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index c7f824958f105..165b3d82b8b1c 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -66,7 +66,7 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Internal function to disable the guard from the given Safe. /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. /// @param _targetSafe The Safe instance to disable the guard from. - function _disableThisGuard(Safe _targetSafe) internal override { + function _disableGuard(Safe _targetSafe) internal override { SafeState storage safeState = _safeState[_targetSafe]; // set the timelock delay to 0 to clear the configuration safeState.timelockDelay = 0; From 819a246d2fa265ed345e73f255173499a4538103 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 10 Oct 2025 09:29:37 -0400 Subject: [PATCH 25/90] bump semver --- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/src/safe/SaferSafes.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index d9e5a831dd474..3fb6720c31037 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0xee8e43aed70900129c457171d570facfc66548572316faf911105ccee9c54667", - "sourceCodeHash": "0xb7a3cbee00174527e95d95835573d6846a09fb7df7b04d144ac59ec4715e978f" + "initCodeHash": "0x6fdb164d16d065aec1e9837534896db2104eca0a07a1c96be8845f288b92231b", + "sourceCodeHash": "0x4e1ba0aa1b1f90f4e0ca60de0b053def16b89159ed01007905497cd2dc0bdf8f" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 165b3d82b8b1c..9096e07d561ba 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -24,8 +24,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// functionality is not desired, then there is no need to enable or configure it. contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Semantic version. - /// @custom:semver 1.0.0 - string public constant version = "1.0.0"; + /// @custom:semver 1.1.0 + string public constant version = "1.1.0"; /// @notice Error for when the liveness response period is insufficient. error SaferSafes_InsufficientLivenessResponsePeriod(); From fcd9fa490dc8f3cc4a0c42b56d31de92ca14789e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 14 Oct 2025 14:54:44 -0400 Subject: [PATCH 26/90] Add test to remove unrelated guard --- .../test/safe/LivenessModule2.t.sol | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 944fbe15837a3..c06653130daf8 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -8,6 +8,8 @@ import "test/safe-tools/SafeTestTools.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; + /// @title LivenessModule2_TestInit /// @notice Reusable test initialization for `LivenessModule2` tests. contract LivenessModule2_TestInit is Test, SafeTestTools { @@ -447,21 +449,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI ); } - function test_changeOwnershipToFallback_succeeds() external { - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - vm.expectEmit(true, true, true, true); - emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); - - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - + function _assertOwnershipChanged(address _safe) internal { // Verify ownership changed address[] memory newOwners = safeInstance.safe.getOwners(); assertEq(newOwners.length, 1); @@ -479,6 +467,24 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(_getGuard(safeInstance), address(0)); } + function test_changeOwnershipToFallback_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + _assertOwnershipChanged(address(safeInstance.safe)); + } + function test_changeOwnershipToFallback_withOtherModules_succeeds() external { // First disable the module, because we want it to be in the middle of the Safe's list // of modules. @@ -518,6 +524,41 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Verify extra modules are still enabled (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); assertEq(modules.length, 6); + + _assertOwnershipChanged(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { + // Create a mock guard + address dummyGuard = makeAddr("dummyGuard"); + vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkTransaction.selector), ""); + vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkAfterExecution.selector), ""); + + // Enable the mock guard on the Safe + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (dummyGuard)), + Enum.Operation.Call + ); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // These checks include ensuring that the guard is deactivated + _assertOwnershipChanged(address(safeInstance.safe)); } function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { From a64340465721600db81d7ed646956951eb4b0254 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 14 Oct 2025 15:09:03 -0400 Subject: [PATCH 27/90] Add SENTINEL_MODULE constant --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 3a8513720e2e3..59032b8cd78c6 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -35,6 +35,9 @@ abstract contract LivenessModule2 { /// @notice Reserved address used as previous owner to the first owner in a Safe. address internal constant SENTINEL_OWNER = address(0x1); + /// @notice Reserved address used as previous module to the first module in a Safe. + address internal constant SENTINEL_MODULE = address(0x1); + /// @notice Error for when module is not enabled for the Safe. error LivenessModule2_ModuleNotEnabled(); @@ -342,10 +345,8 @@ abstract contract LivenessModule2 { /// @param _targetSafe The Safe instance to disable this module from. function _disableThisModule(Safe _targetSafe) internal { // Get current modules - // We use SENTINEL_OWNER because it's already defined in this contract, and has the same value - // as the SENTINEL_MODULES address used in ModuleManager. // This might not work if you have more than 100 modules, but what are you even doing if that's the case? - (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_OWNER, 100); + (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_MODULE, 100); // Find the index of this module bool moduleFound = false; From a2d60195f8cab26106b3e53cf5c2efa582d0735b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 14 Oct 2025 15:12:59 -0400 Subject: [PATCH 28/90] Clean up using ternary if --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 59032b8cd78c6..78de336709d09 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -360,12 +360,10 @@ abstract contract LivenessModule2 { } if (moduleFound) { // If the module is the first in the list, then the previous module is the sentinel. - address prevModule = SENTINEL_OWNER; // If the module is not the first in the list, then the previous module is the module before in in the // array. - if (moduleIndex > 0) { - prevModule = modules[moduleIndex - 1]; - } + address prevModule = moduleIndex == 0 ? SENTINEL_MODULE : modules[moduleIndex - 1]; + // Disable the module _targetSafe.execTransactionFromModule({ to: address(_targetSafe), From 539dd587b8a3e6e22fb17ae215635d8112a15841 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 14 Oct 2025 15:34:04 -0400 Subject: [PATCH 29/90] Reset cancellationThreshold to 0 on changeOwnership --- packages/contracts-bedrock/src/safe/SaferSafes.sol | 2 +- packages/contracts-bedrock/test/safe/LivenessModule2.t.sol | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 9096e07d561ba..ef2eb7ac6ddfd 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -72,7 +72,7 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { safeState.timelockDelay = 0; // Reset the cancellation threshold, 1 is the default value for all safes. - safeState.cancellationThreshold = 1; + safeState.cancellationThreshold = 0; // Disable the guard // Note that this will remove whichever guard is currently set on the Safe, diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index c06653130daf8..ea55e8de1cbd7 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -465,6 +465,11 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Verify guard is deactivated assertEq(_getGuard(safeInstance), address(0)); + TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); + + // Ensure TimelockGuard properties are cleared + assertEq(timelockGuard.timelockConfiguration(safeInstance.safe), 0); + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 0); } function test_changeOwnershipToFallback_succeeds() external { From 9db0218907f85a84710fe17083c687282de7a560 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 14 Oct 2025 15:46:16 -0400 Subject: [PATCH 30/90] Fix moduleFound if/else handling --- .../src/safe/LivenessModule2.sol | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 78de336709d09..dfee4d825db80 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -358,22 +358,21 @@ abstract contract LivenessModule2 { break; } } - if (moduleFound) { - // If the module is the first in the list, then the previous module is the sentinel. - // If the module is not the first in the list, then the previous module is the module before in in the - // array. - address prevModule = moduleIndex == 0 ? SENTINEL_MODULE : modules[moduleIndex - 1]; - - // Disable the module - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(ModuleManager.disableModule, (prevModule, address(this))) - }); - } - // Clear the module configuration + if (!moduleFound) return; + + // If the module is the first in the list, then the previous module is the sentinel. + // Otherwise, the previous module is the module before in the array. + address prevModule = moduleIndex == 0 ? SENTINEL_MODULE : modules[moduleIndex - 1]; + + // Disable the module + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(ModuleManager.disableModule, (prevModule, address(this))) + }); + // Erase the configuration data for this safe delete livenessSafeConfiguration[address(_targetSafe)]; } From f7763dc444411def373d3d4ac0b33ba69bcb93ce Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 11:00:15 -0400 Subject: [PATCH 31/90] Clear pending transactions --- .../contracts-bedrock/src/safe/SaferSafes.sol | 20 + .../test/safe/LivenessModule2.t.sol | 387 ++++-------------- .../test/safe/SaferSafes.t.sol | 261 +++++++++++- 3 files changed, 358 insertions(+), 310 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index ef2eb7ac6ddfd..295abda6dbe44 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -11,6 +11,9 @@ import { LivenessModule2 } from "./LivenessModule2.sol"; import { TimelockGuard } from "./TimelockGuard.sol"; import { ISemver } from "interfaces/universal/ISemver.sol"; +// Libraries +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + /// @title SaferSafes /// @notice Combined Safe extensions providing both liveness module and timelock guard functionality /// @dev This contract can be enabled simultaneously as both a module and a guard on a Safe: @@ -23,6 +26,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// When installing either component, it should first be enabled, and then configured. If a component's /// functionality is not desired, then there is no need to enable or configure it. contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { + using EnumerableSet for EnumerableSet.Bytes32Set; + /// @notice Semantic version. /// @custom:semver 1.1.0 string public constant version = "1.1.0"; @@ -63,6 +68,8 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { } } + // TODO: should this be moved into the TimelockGuard contract? + // Or should this be exposed a public function "clearTimelockGuard" function? /// @notice Internal function to disable the guard from the given Safe. /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. /// @param _targetSafe The Safe instance to disable the guard from. @@ -74,6 +81,19 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { // Reset the cancellation threshold, 1 is the default value for all safes. safeState.cancellationThreshold = 0; + // Get all pending transaction hashes + bytes32[] memory hashes = safeState.pendingTxHashes.values(); + + // Cancel all pending transactions + // It is true that iterating over a very large array can lead to gas issues, however the number of pending + // transactions is not expected to be large. If it grows to a point where this becomes an issue, then it maybe + // be necessary to manually cancel enough transactions to reduce the array size to a manageable size. + for (uint256 i = 0; i < hashes.length; i++) { + safeState.pendingTxHashes.remove(hashes[i]); + safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; + emit TransactionCancelled(_targetSafe, hashes[i]); + } + // Disable the guard // Note that this will remove whichever guard is currently set on the Safe, // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index ea55e8de1cbd7..50385286f4358 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -9,10 +9,73 @@ import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { TimelockGuard } from "src/safe/TimelockGuard.sol"; + +/// @title LivenessModule2_TestUtils +/// @notice Reusable helper methods for LivenessModule2 tests. +abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { + /// @notice Helper to enable the LivenessModule2 for a Safe + function _enableModule( + SafeInstance memory _safe, + uint256 _period, + address _fallback, + SaferSafes _livenessModule2 + ) + internal + { + LivenessModule2.ModuleConfig memory config = + LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); + SafeTestLib.execTransaction( + _safe, + address(_livenessModule2), + 0, + abi.encodeCall(LivenessModule2.configureLivenessModule, (config)), + Enum.Operation.Call + ); + } + + /// @notice Helper to disable the LivenessModule2 for a Safe + function _disableModule(SafeInstance memory _safe, SaferSafes _livenessModule2) internal { + // First disable the module at the Safe level + SafeTestLib.execTransaction( + _safe, + address(_safe.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(_livenessModule2))), + Enum.Operation.Call + ); + + // Then clear the module configuration + SafeTestLib.execTransaction( + _safe, + address(_livenessModule2), + 0, + abi.encodeCall(LivenessModule2.clearLivenessModule, ()), + Enum.Operation.Call + ); + } + + /// @notice Helper to respond to a challenge from a Safe + function _respondToChallenge(SafeInstance memory _safe, SaferSafes _livenessModule2) internal { + SafeTestLib.execTransaction( + _safe, address(_livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call + ); + } + + /// @notice Helper to get the guard address from a Safe + function _getGuard(SafeInstance memory _safe) internal view returns (address) { + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_safe.safe.getStorageAt(uint256(guardSlot), 1), (address)); + return guard; + } +} /// @title LivenessModule2_TestInit /// @notice Reusable test initialization for `LivenessModule2` tests. -contract LivenessModule2_TestInit is Test, SafeTestTools { +contract LivenessModule2_TestInit is LivenessModule2_TestUtils { using SafeTestLib for SafeInstance; // Events @@ -53,54 +116,6 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { // Enable the module on the Safe SafeTestLib.enableModule(safeInstance, address(livenessModule2)); } - - /// @notice Helper to enable the LivenessModule2 for a Safe - function _enableModule(SafeInstance memory _safe, uint256 _period, address _fallback) internal { - LivenessModule2.ModuleConfig memory config = - LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); - SafeTestLib.execTransaction( - _safe, - address(livenessModule2), - 0, - abi.encodeCall(LivenessModule2.configureLivenessModule, (config)), - Enum.Operation.Call - ); - } - - /// @notice Helper to disable the LivenessModule2 for a Safe - function _disableModule(SafeInstance memory _safe) internal { - // First disable the module at the Safe level - SafeTestLib.execTransaction( - _safe, - address(_safe.safe), - 0, - abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), - Enum.Operation.Call - ); - - // Then clear the module configuration - SafeTestLib.execTransaction( - _safe, - address(livenessModule2), - 0, - abi.encodeCall(LivenessModule2.clearLivenessModule, ()), - Enum.Operation.Call - ); - } - - /// @notice Helper to respond to a challenge from a Safe - function _respondToChallenge(SafeInstance memory _safe) internal { - SafeTestLib.execTransaction( - _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call - ); - } - - function _getGuard(SafeInstance memory _safe) internal view returns (address) { - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(_safe.safe.getStorageAt(uint256(guardSlot), 1), (address)); - return guard; - } } /// @title LivenessModule2_Configure_Test @@ -110,7 +125,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni vm.expectEmit(true, true, true, true); emit ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period, CHALLENGE_PERIOD); @@ -137,9 +152,9 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni address fallback3 = makeAddr("fallback3"); // Configure module for each safe - _enableModule(safe1, 1 days, fallback1); - _enableModule(safe2, 2 days, fallback2); - _enableModule(safe3, 3 days, fallback3); + _enableModule(safe1, 1 days, fallback1, livenessModule2); + _enableModule(safe2, 2 days, fallback2, livenessModule2); + _enableModule(safe3, 3 days, fallback3, livenessModule2); // Verify each safe has independent configuration (uint256 period1, address fb1) = livenessModule2.livenessSafeConfiguration(address(safe1.safe)); @@ -189,7 +204,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni function test_configureLivenessModule_cancelsExistingChallenge_succeeds() external { // First configure the module - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Start a challenge vm.prank(fallbackOwner); @@ -216,7 +231,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni } function test_clear_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // First disable the module at the Safe level SafeTestLib.execTransaction( @@ -251,7 +266,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni } function test_clear_moduleStillEnabled_reverts() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Try to clear while module is still enabled (should revert) vm.expectRevert(LivenessModule2.LivenessModule2_ModuleStillEnabled.selector); @@ -265,7 +280,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function setUp() public override { super.setUp(); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); } function test_challenge_succeeds() external { @@ -313,7 +328,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeTestLib.enableModule(disabledSafe, address(livenessModule2)); // Then configure - _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Now disable the module at Safe level (but keep config) SafeTestLib.execTransaction( @@ -339,7 +354,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.expectEmit(true, true, true, true); emit ChallengeCancelled(address(safeInstance.safe)); - _respondToChallenge(safeInstance); + _respondToChallenge(safeInstance, livenessModule2); // Verify challenge is cancelled uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); @@ -407,7 +422,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeTestLib.enableModule(configuredSafe, address(livenessModule2)); // Configure the module (this sets the configuration) - _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Now disable the module at Safe level (but keep config) SafeTestLib.execTransaction( @@ -431,252 +446,6 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { } } -/// @title LivenessModule2_ChangeOwnershipToFallback_Test -/// @notice Tests the ownership transfer after successful challenge -contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestInit { - function setUp() public override { - super.setUp(); - - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // enable the guard - SafeTestLib.execTransaction( - safeInstance, - address(safeInstance.safe), - 0, - abi.encodeCall(GuardManager.setGuard, (address(livenessModule2))), - Enum.Operation.Call - ); - } - - function _assertOwnershipChanged(address _safe) internal { - // Verify ownership changed - address[] memory newOwners = safeInstance.safe.getOwners(); - assertEq(newOwners.length, 1); - assertEq(newOwners[0], fallbackOwner); - assertEq(safeInstance.safe.getThreshold(), 1); - - // Verify challenge is reset - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); - assertEq(challengeEndTime, 0); - - // Verify module is disabled - assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); - - // Verify guard is deactivated - assertEq(_getGuard(safeInstance), address(0)); - TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); - - // Ensure TimelockGuard properties are cleared - assertEq(timelockGuard.timelockConfiguration(safeInstance.safe), 0); - assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 0); - } - - function test_changeOwnershipToFallback_succeeds() external { - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - vm.expectEmit(true, true, true, true); - emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); - - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - _assertOwnershipChanged(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_withOtherModules_succeeds() external { - // First disable the module, because we want it to be in the middle of the Safe's list - // of modules. - _disableModule(safeInstance); - - // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle - // multiple modules and only remove the correct one. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); - - // Enable the LivenessModule2 on the Safe - SafeTestLib.enableModule(safeInstance, address(livenessModule2)); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Enable a few more modules after LivenessModule2. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); - - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - vm.expectEmit(true, true, true, true); - emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); - - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Verify module is disabled - assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); - // Verify extra modules are still enabled - (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); - assertEq(modules.length, 6); - - _assertOwnershipChanged(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { - // Create a mock guard - address dummyGuard = makeAddr("dummyGuard"); - vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkTransaction.selector), ""); - vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkAfterExecution.selector), ""); - - // Enable the mock guard on the Safe - SafeTestLib.execTransaction( - safeInstance, - address(safeInstance.safe), - 0, - abi.encodeCall(GuardManager.setGuard, (dummyGuard)), - Enum.Operation.Call - ); - - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - vm.expectEmit(true, true, true, true); - emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); - - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // These checks include ensuring that the guard is deactivated - _assertOwnershipChanged(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { - address newSafe = makeAddr("newSafe"); - - vm.prank(fallbackOwner); - vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); - livenessModule2.changeOwnershipToFallback(newSafe); - } - - function test_changeOwnershipToFallback_noChallenge_reverts() external { - vm.prank(fallbackOwner); - vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Try to execute before response period expires - vm.prank(fallbackOwner); - vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_moduleDisabledAtSafeLevel_reverts() external { - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Disable the module at Safe level - SafeTestLib.execTransaction( - safeInstance, - address(safeInstance.safe), - 0, - abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), - Enum.Operation.Call - ); - - // Try to execute ownership transfer - should revert because module is disabled at Safe level - vm.prank(fallbackOwner); - vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - } - - function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Try from random address - should fail - address randomCaller = makeAddr("randomCaller"); - vm.prank(randomCaller); - vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Execute from fallback owner - should succeed - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Verify ownership changed - address[] memory newOwners = safeInstance.safe.getOwners(); - assertEq(newOwners.length, 1); - assertEq(newOwners[0], fallbackOwner); - } - - function test_changeOwnershipToFallback_canRechallenge_succeeds() external { - // Start and execute first challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Re-enable the module - vm.prank(fallbackOwner); - safeInstance.safe.execTransaction( - address(safeInstance.safe), - 0, - abi.encodeCall(ModuleManager.enableModule, (address(livenessModule2))), - Enum.Operation.Call, - 0, - 0, - 0, - address(0), - payable(address(0)), - abi.encodePacked(bytes32(uint256(uint160(fallbackOwner))), bytes32(0), uint8(1)) - ); - - // Re-configure the module - vm.prank(address(safeInstance.safe)); - livenessModule2.configureLivenessModule( - LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) - ); - - // Start a new challenge (as fallback owner) - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); - assertGt(challengeEndTime, 0); - } -} - /// @title LivenessModule2_GetChallengePeriodEnd_Test /// @notice Tests the getChallengePeriodEnd function and related view functionality contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit { @@ -688,7 +457,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); // After enabling - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); (uint256 period2, address fbOwner2) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period2, CHALLENGE_PERIOD); assertEq(fbOwner2, fallbackOwner); @@ -696,7 +465,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit } function test_getChallengePeriodEnd_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // No challenge assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); @@ -707,7 +476,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); // After cancellation - _respondToChallenge(safeInstance); + _respondToChallenge(safeInstance, livenessModule2); assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 5bf776036759c..e9dce098b443c 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -2,25 +2,37 @@ pragma solidity 0.8.15; import { Test } from "forge-std/Test.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { TimelockGuard } from "src/safe/TimelockGuard.sol"; + +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; + +// Import the test utils from LivenessModule2 tests +import { LivenessModule2_TestUtils } from "test/safe/LivenessModule2.t.sol"; /// @title SaferSafes_TestInit /// @notice Reusable test initialization for `SaferSafes` tests. -contract SaferSafes_TestInit is Test, SafeTestTools { +contract SaferSafes_TestInit is LivenessModule2_TestUtils { using SafeTestLib for SafeInstance; // Events event ModuleConfigured(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); event GuardConfigured(address indexed safe, uint256 timelockDelay, uint256 cancellationThreshold); + event ChallengeSucceeded(address indexed safe, address fallbackOwner); uint256 constant INIT_TIME = 10; uint256 constant NUM_OWNERS = 5; uint256 constant THRESHOLD = 3; + uint256 constant CHALLENGE_PERIOD = 7 days; SaferSafes saferSafes; + SaferSafes livenessModule2; SafeInstance safeInstance; address fallbackOwner; address[] owners; @@ -31,6 +43,7 @@ contract SaferSafes_TestInit is Test, SafeTestTools { // Deploy the SaferSafes contract saferSafes = new SaferSafes(); + livenessModule2 = saferSafes; // Create Safe owners (address[] memory _owners, uint256[] memory _keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); @@ -144,3 +157,249 @@ contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { saferSafes.configureLivenessModule(moduleConfig); } } + +/// @title LivenessModule2_ChangeOwnershipToFallback_Test +/// @notice Tests the ownership transfer after successful challenge +contract LivenessModule2_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { + function setUp() public override { + super.setUp(); + + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + + // enable the guard + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (address(livenessModule2))), + Enum.Operation.Call + ); + } + + function _assertOwnershipChanged(address _safe) internal { + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + assertEq(safeInstance.safe.getThreshold(), 1); + + // Verify challenge is reset + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + + // Verify module is disabled + assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); + + // Verify guard is deactivated + assertEq(_getGuard(safeInstance), address(0)); + TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); + + // Ensure TimelockGuard properties are cleared + assertEq(timelockGuard.timelockConfiguration(safeInstance.safe), 0); + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 0); + } + + function test_changeOwnershipToFallback_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + _assertOwnershipChanged(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_withOtherModules_succeeds() external { + // First disable the module, because we want it to be in the middle of the Safe's list + // of modules. + _disableModule(safeInstance, livenessModule2); + + // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle + // multiple modules and only remove the correct one. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); + + // Enable the LivenessModule2 on the Safe + SafeTestLib.enableModule(safeInstance, address(livenessModule2)); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + + // Enable a few more modules after LivenessModule2. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); + SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify module is disabled + assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); + // Verify extra modules are still enabled + (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); + assertEq(modules.length, 6); + + _assertOwnershipChanged(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { + // Create a mock guard + address dummyGuard = makeAddr("dummyGuard"); + vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkTransaction.selector), ""); + vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkAfterExecution.selector), ""); + + // Enable the mock guard on the Safe + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (dummyGuard)), + Enum.Operation.Call + ); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // These checks include ensuring that the guard is deactivated + _assertOwnershipChanged(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + livenessModule2.changeOwnershipToFallback(newSafe); + } + + function test_changeOwnershipToFallback_noChallenge_reverts() external { + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Try to execute before response period expires + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_moduleDisabledAtSafeLevel_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Disable the module at Safe level + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Try to execute ownership transfer - should revert because module is disabled at Safe level + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Try from random address - should fail + address randomCaller = makeAddr("randomCaller"); + vm.prank(randomCaller); + vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Execute from fallback owner - should succeed + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + } + + function test_changeOwnershipToFallback_canRechallenge_succeeds() external { + // Start and execute first challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Re-enable the module + vm.prank(fallbackOwner); + safeInstance.safe.execTransaction( + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.enableModule, (address(livenessModule2))), + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(address(0)), + abi.encodePacked(bytes32(uint256(uint160(fallbackOwner))), bytes32(0), uint8(1)) + ); + + // Re-configure the module + vm.prank(address(safeInstance.safe)); + livenessModule2.configureLivenessModule( + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) + ); + + // Start a new challenge (as fallback owner) + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertGt(challengeEndTime, 0); + } +} From f0855e5510551b4975387d9cb1e125837b658110 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 11:58:39 -0400 Subject: [PATCH 32/90] Pre-pr fixes --- .../contracts-bedrock/snapshots/semver-lock.json | 4 ++-- .../test/safe/LivenessModule2.t.sol | 3 --- .../contracts-bedrock/test/safe/SaferSafes.t.sol | 12 +++++++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 3fb6720c31037..7b228efd5acf1 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x6fdb164d16d065aec1e9837534896db2104eca0a07a1c96be8845f288b92231b", - "sourceCodeHash": "0x4e1ba0aa1b1f90f4e0ca60de0b053def16b89159ed01007905497cd2dc0bdf8f" + "initCodeHash": "0xad31863f5d7bb03c9d72b0811b22587d9840f10a4c29af83458d1d308c7eff10", + "sourceCodeHash": "0x3a04bf9253431adef0cdf9b8469da0ea3148829bb9bd6813f671c07d15fa7a2a" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 50385286f4358..a45e25ac7b420 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -8,10 +8,7 @@ import "test/safe-tools/SafeTestTools.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; -import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; -import { GuardManager } from "safe-contracts/base/GuardManager.sol"; -import { TimelockGuard } from "src/safe/TimelockGuard.sol"; /// @title LivenessModule2_TestUtils /// @notice Reusable helper methods for LivenessModule2 tests. diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index e9dce098b443c..e08257d3e5211 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -1,7 +1,6 @@ // 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 "test/safe-tools/SafeTestTools.sol"; @@ -263,8 +262,15 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { // Create a mock guard address dummyGuard = makeAddr("dummyGuard"); - vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkTransaction.selector), ""); - vm.mockCall(dummyGuard, abi.encodeWithSelector(IGuard.checkAfterExecution.selector), ""); + vm.mockCall( + dummyGuard, + abi.encodeCall( + IGuard.checkTransaction, + (address(0), 0, "", Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)), "", address(0)) + ), + "" + ); + vm.mockCall(dummyGuard, abi.encodeCall(IGuard.checkAfterExecution, (bytes32(0), false)), ""); // Enable the mock guard on the Safe SafeTestLib.execTransaction( From 6b2b25dedf25ac0741c3fac6eb51c1a0cfa749ce Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 13:55:55 -0400 Subject: [PATCH 33/90] Add test contract to test name lint exclusions --- .../scripts/checks/test-validation/exclusions.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml index 6c332b4e8a321..cf00ca4e9ca9c 100644 --- a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml +++ b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml @@ -94,4 +94,5 @@ contracts = [ "OptimismPortal2_UpgradeInterop_Test", # Interop tests hosted in the OptimismPortal2 test file "TransactionBuilder", # Transaction builder helper library in TimelockGuard test file "Constants_Test", # Invalid naming pattern - doesn't specify function or Uncategorized + "LivenessModule2_TestUtils", # Test utils library in LivenessModule2 test file ] From cb58df1ef7afb90894acc41b09f6e68cf30341e1 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 14:44:48 -0400 Subject: [PATCH 34/90] fix name of test contract --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index e08257d3e5211..19015f7b523ee 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -157,9 +157,9 @@ contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { } } -/// @title LivenessModule2_ChangeOwnershipToFallback_Test +/// @title SaferSafes_ChangeOwnershipToFallback_Test /// @notice Tests the ownership transfer after successful challenge -contract LivenessModule2_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { +contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { function setUp() public override { super.setUp(); From 8dd8e551c9c39633d273fef473cae81785eaf11f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 15:02:49 -0400 Subject: [PATCH 35/90] Move _disableGuard impl into TimelockGuard --- .../contracts-bedrock/src/safe/SaferSafes.sol | 45 ++----------------- .../src/safe/TimelockGuard.sol | 44 +++++++++++++++++- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 295abda6dbe44..43a704f620204 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -3,17 +3,12 @@ pragma solidity 0.8.15; // Safe import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; -import { GuardManager } from "safe-contracts/base/GuardManager.sol"; -import { Enum } from "safe-contracts/common/Enum.sol"; // Safe Extensions import { LivenessModule2 } from "./LivenessModule2.sol"; import { TimelockGuard } from "./TimelockGuard.sol"; import { ISemver } from "interfaces/universal/ISemver.sol"; -// Libraries -import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - /// @title SaferSafes /// @notice Combined Safe extensions providing both liveness module and timelock guard functionality /// @dev This contract can be enabled simultaneously as both a module and a guard on a Safe: @@ -26,8 +21,6 @@ import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableS /// When installing either component, it should first be enabled, and then configured. If a component's /// functionality is not desired, then there is no need to enable or configure it. contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { - using EnumerableSet for EnumerableSet.Bytes32Set; - /// @notice Semantic version. /// @custom:semver 1.1.0 string public constant version = "1.1.0"; @@ -68,42 +61,10 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { } } - // TODO: should this be moved into the TimelockGuard contract? - // Or should this be exposed a public function "clearTimelockGuard" function? /// @notice Internal function to disable the guard from the given Safe. - /// @dev This function is intended for use in the SaferSafes contract, which extends this contract. + /// @dev This function is a wrapper that calls the parent TimelockGuard implementation. /// @param _targetSafe The Safe instance to disable the guard from. - function _disableGuard(Safe _targetSafe) internal override { - SafeState storage safeState = _safeState[_targetSafe]; - // set the timelock delay to 0 to clear the configuration - safeState.timelockDelay = 0; - - // Reset the cancellation threshold, 1 is the default value for all safes. - safeState.cancellationThreshold = 0; - - // Get all pending transaction hashes - bytes32[] memory hashes = safeState.pendingTxHashes.values(); - - // Cancel all pending transactions - // It is true that iterating over a very large array can lead to gas issues, however the number of pending - // transactions is not expected to be large. If it grows to a point where this becomes an issue, then it maybe - // be necessary to manually cancel enough transactions to reduce the array size to a manageable size. - for (uint256 i = 0; i < hashes.length; i++) { - safeState.pendingTxHashes.remove(hashes[i]); - safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; - emit TransactionCancelled(_targetSafe, hashes[i]); - } - - // Disable the guard - // Note that this will remove whichever guard is currently set on the Safe, - // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard - // itself was the cause of the liveness failure which resulted in the transfer of ownership to - // the fallback owner. - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); + function _disableGuard(Safe _targetSafe) internal override(LivenessModule2, TimelockGuard) { + TimelockGuard._disableGuard(_targetSafe); } } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 183b2a46c8e70..9f5f7e6831d3d 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 { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { Guard as IGuard, GuardManager } from "safe-contracts/base/GuardManager.sol"; // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; @@ -612,4 +612,46 @@ abstract contract TimelockGuard is IGuard { function signCancellation(bytes32) public { emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } + + //////////////////////////////////////////////////////////////// + // Internal Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Internal function to disable the guard from the given Safe. + /// @dev This function is intended for use in contracts that extend TimelockGuard. + /// It clears the timelock guard configuration and cancels all pending transactions. + /// @param _targetSafe The Safe instance to disable the guard from. + function _disableGuard(Safe _targetSafe) internal virtual { + SafeState storage safeState = _safeState[_targetSafe]; + // set the timelock delay to 0 to clear the configuration + safeState.timelockDelay = 0; + + // Reset the cancellation threshold, 1 is the default value for all safes. + safeState.cancellationThreshold = 0; + + // Get all pending transaction hashes + bytes32[] memory hashes = safeState.pendingTxHashes.values(); + + // Cancel all pending transactions + // It is true that iterating over a very large array can lead to gas issues, however the number of pending + // transactions is not expected to be large. If it grows to a point where this becomes an issue, then it maybe + // be necessary to manually cancel enough transactions to reduce the array size to a manageable size. + for (uint256 i = 0; i < hashes.length; i++) { + safeState.pendingTxHashes.remove(hashes[i]); + safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; + emit TransactionCancelled(_targetSafe, hashes[i]); + } + + // Disable the guard + // Note that this will remove whichever guard is currently set on the Safe, + // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard + // itself was the cause of the liveness failure which resulted in the transfer of ownership to + // the fallback owner. + _targetSafe.execTransactionFromModule({ + to: address(_targetSafe), + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); + } } From a36b79e44ed84a8cf7365bb1c04215b8368fa4e6 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 15:19:31 -0400 Subject: [PATCH 36/90] Add missing natspec --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index dfee4d825db80..dec0988cc862d 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -150,6 +150,7 @@ abstract contract LivenessModule2 { /// @notice Internal helper function which can be overriden in a child contract to check if the guard's /// configuration is valid in the context of other extensions that are enabled on the Safe. + /// @param _safe The Safe instance to check the configuration against function _checkCombinedConfig(Safe _safe) internal view virtual; /// @notice Clears the module configuration for a Safe. From 62fed33feac9bd999ad6c09f5f97bc24811268b5 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 15:19:59 -0400 Subject: [PATCH 37/90] Add gas limit testing on changeOwnershipToFallback --- .../test/safe/SaferSafes.t.sol | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 19015f7b523ee..ef19ad7fe4dd9 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -221,20 +221,18 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // of modules. _disableModule(safeInstance, livenessModule2); - // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle - // multiple modules and only remove the correct one. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module1"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module2"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module3"))); + // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle up to 100 + // modules. + for (uint256 i = 0; i < 98; i++) { + SafeTestLib.enableModule(safeInstance, address(makeAddr(string(abi.encodePacked("module", i))))); + } // Enable the LivenessModule2 on the Safe SafeTestLib.enableModule(safeInstance, address(livenessModule2)); _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); - // Enable a few more modules after LivenessModule2. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module4"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module5"))); - SafeTestLib.enableModule(safeInstance, address(makeAddr("module6"))); + // Enable 1 more module to bring us to 100 modules. + SafeTestLib.enableModule(safeInstance, address(makeAddr("module100"))); // Start a challenge vm.prank(fallbackOwner); @@ -250,11 +248,14 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { vm.prank(fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + // Ensure that the call is below a safe gas limit. The EIP-7825 limit is 16,777,216, so 12M is a safe limit. + assertLt(vm.lastCallGas().gasTotalUsed, 12_000_000); + // Verify module is disabled assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); // Verify extra modules are still enabled (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); - assertEq(modules.length, 6); + assertEq(modules.length, 99); _assertOwnershipChanged(address(safeInstance.safe)); } From bd032889b639b35b8f512f941de3a924699fe44c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 15:24:04 -0400 Subject: [PATCH 38/90] Remove interfaces for abstract contracts --- .../interfaces/safe/ILivenessModule2.sol | 48 ---------- .../interfaces/safe/ITimelockGuard.sol | 91 ------------------- 2 files changed, 139 deletions(-) delete mode 100644 packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol delete mode 100644 packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol deleted file mode 100644 index 921d1a794c776..0000000000000 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -/// @title ILivenessModule2 -/// @notice Interface for LivenessModule2, a singleton module for challenge-based ownership transfer -interface ILivenessModule2 { - /// @notice Configuration for a Safe's liveness module - struct ModuleConfig { - uint256 livenessResponsePeriod; - address fallbackOwner; - } - - /// @notice Returns the configuration for a Safe - /// @return livenessResponsePeriod The response period - /// @return fallbackOwner The fallback owner address - function livenessSafeConfiguration(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner); - - /// @notice Returns the challenge start time for a Safe (0 if no challenge) - /// @return The challenge start timestamp - function challengeStartTime(address) external view returns (uint256); - - /// @notice Semantic version - /// @return version The contract version - function version() external view returns (string memory); - - /// @notice Configures the module for a Safe that has already enabled it - /// @param _config The configuration parameters for the module - function configureLivenessModule(ModuleConfig memory _config) external; - - /// @notice Clears the module configuration for a Safe - function clearLivenessModule() external; - - /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge, or 0 if not - /// @param _safe The Safe address to query - /// @return The challenge end timestamp, or 0 if no challenge - function getChallengePeriodEnd(address _safe) external view returns (uint256); - - /// @notice Challenges an enabled safe - /// @param _safe The Safe to challenge - function challenge(address _safe) external; - - /// @notice Responds to a challenge for an enabled safe, canceling it - function respond() external; - - /// @notice Removes all current owners from an enabled safe and appoints fallback as sole owner - /// @param _safe The Safe to transfer ownership of - function changeOwnershipToFallback(address _safe) external; -} diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol deleted file mode 100644 index 984309a87c45c..0000000000000 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.4; - -library Enum { - type Operation is uint8; -} - -interface ITimelockGuard { - enum TransactionState { - NotScheduled, - Pending, - Cancelled, - Executed - } - struct ScheduledTransaction { - uint256 executionTime; - TransactionState state; - ExecTransactionParams params; - } - - struct ExecTransactionParams { - address to; - uint256 value; - bytes data; - Enum.Operation operation; - uint256 safeTxGas; - uint256 baseGas; - uint256 gasPrice; - address gasToken; - address payable refundReceiver; - } - - error TimelockGuard_GuardNotConfigured(); - error TimelockGuard_GuardNotEnabled(); - error TimelockGuard_GuardStillEnabled(); - error TimelockGuard_InvalidTimelockDelay(); - error TimelockGuard_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); - 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 signCancellation(bytes32 _txHash) external; - function cancellationThreshold(address _safe) external view returns (uint256); - 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; - function checkAfterExecution(bytes32, bool) external; - function configureTimelockGuard(uint256 _timelockDelay) external; - function scheduledTransaction( - address _safe, - bytes32 _txHash - ) - external - view - returns (ScheduledTransaction memory); - function safeConfigs(address) external view returns (uint256 timelockDelay); - function scheduleTransaction( - address _safe, - uint256 _nonce, - ExecTransactionParams memory _params, - bytes memory _signatures - ) - external; - function timelockConfiguration(address _safe) external view returns (uint256 timelockDelay); - function maxCancellationThreshold(address _safe) external view returns (uint256); - function pendingTransactions(address _safe) - external - view - returns (ScheduledTransaction[] memory); -} From f75e01940a1cfa2d9f4b8a1b87dfb6507624c8fb Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 16:05:01 -0400 Subject: [PATCH 39/90] Move state changes out into internal _clearLivenessModule --- .../src/safe/LivenessModule2.sol | 21 ++++++++++++------- .../test/safe/SaferSafes.t.sol | 3 +++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index dec0988cc862d..06737cd196535 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -162,7 +162,7 @@ abstract contract LivenessModule2 { /// 3. If Safe later re-enables the module, it must call configureLivenessModule() again. /// Never calling clearLivenessModule() after disabling keeps configuration data persistent /// for potential future re-enabling. - function clearLivenessModule() external { + function clearLivenessModule() public { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); @@ -170,11 +170,8 @@ abstract contract LivenessModule2 { // This prevents clearing configuration while module is still enabled _assertModuleNotEnabled(msg.sender); - // Erase the configuration data for this safe - delete livenessSafeConfiguration[msg.sender]; - // Also clear any active challenge - _cancelChallenge(msg.sender); - emit ModuleCleared(msg.sender); + // Clear the configuration and any active challenge + _clearLivenessModule(Safe(payable(msg.sender))); } /// @notice Challenges an enabled safe. @@ -336,6 +333,16 @@ abstract contract LivenessModule2 { emit ChallengeCancelled(_safe); } + /// @notice Internal function to clear the liveness module configuration and any active challenge. + /// @param _safe The Safe instance to clear the configuration for. + function _clearLivenessModule(Safe _safe) internal { + // Erase the configuration data for this safe + delete livenessSafeConfiguration[address(_safe)]; + // Also clear any active challenge + _cancelChallenge(address(_safe)); + emit ModuleCleared(address(_safe)); + } + /// @notice Internal function to disable this guard from the given Safe. /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another /// guard is enabled. @@ -375,6 +382,6 @@ abstract contract LivenessModule2 { }); // Erase the configuration data for this safe - delete livenessSafeConfiguration[address(_targetSafe)]; + _clearLivenessModule(_targetSafe); } } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 5a4af699db39f..c10f42cd1bed0 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -261,6 +261,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Create a mock guard address dummyGuard = makeAddr("dummyGuard"); vm.mockCall( @@ -349,6 +350,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Start a challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); @@ -373,6 +375,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_canRechallenge_succeeds() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); // Start and execute first challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); From b50c952e9fc606683929d5184f526fc40f3a428a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 16:20:11 -0400 Subject: [PATCH 40/90] Improve names on the internal _disableX methods --- .../contracts-bedrock/src/safe/LivenessModule2.sol | 11 ++++++----- packages/contracts-bedrock/src/safe/SaferSafes.sol | 4 ++-- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 06737cd196535..71845076eba39 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -289,11 +289,12 @@ abstract contract LivenessModule2 { // Now we will disable the guard and module from the Safe, so that the Safe is set to a // minimal state, which is as simple as possible for the fallback owner to reason about. - // Disable this guard from the Safe (if and only if it is enabled). - _disableGuard(targetSafe); + // Disable and clear this guard. + // Removes whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. + _disableAndClearGuard(targetSafe); // Disable this module from the Safe - _disableThisModule(targetSafe); + _disableAndClearThisModule(targetSafe); } /// @notice Asserts that the module is configured for the given Safe. @@ -347,11 +348,11 @@ abstract contract LivenessModule2 { /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another /// guard is enabled. /// @param _targetSafe The Safe instance to disable this guard from. - function _disableGuard(Safe _targetSafe) internal virtual; + function _disableAndClearGuard(Safe _targetSafe) internal virtual; /// @notice Internal function to disable this module from the given Safe. /// @param _targetSafe The Safe instance to disable this module from. - function _disableThisModule(Safe _targetSafe) internal { + function _disableAndClearThisModule(Safe _targetSafe) internal { // Get current modules // This might not work if you have more than 100 modules, but what are you even doing if that's the case? (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_MODULE, 100); diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 43a704f620204..9bb0806bb6b22 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -64,7 +64,7 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Internal function to disable the guard from the given Safe. /// @dev This function is a wrapper that calls the parent TimelockGuard implementation. /// @param _targetSafe The Safe instance to disable the guard from. - function _disableGuard(Safe _targetSafe) internal override(LivenessModule2, TimelockGuard) { - TimelockGuard._disableGuard(_targetSafe); + function _disableAndClearGuard(Safe _targetSafe) internal override(LivenessModule2, TimelockGuard) { + TimelockGuard._disableAndClearGuard(_targetSafe); } } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 9f5f7e6831d3d..ff31e9e5f11a0 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -621,7 +621,7 @@ abstract contract TimelockGuard is IGuard { /// @dev This function is intended for use in contracts that extend TimelockGuard. /// It clears the timelock guard configuration and cancels all pending transactions. /// @param _targetSafe The Safe instance to disable the guard from. - function _disableGuard(Safe _targetSafe) internal virtual { + function _disableAndClearGuard(Safe _targetSafe) internal virtual { SafeState storage safeState = _safeState[_targetSafe]; // set the timelock delay to 0 to clear the configuration safeState.timelockDelay = 0; From 866067b1fb56f6a958c095f8fefbcf5b3a6bacdf Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 21:02:28 -0400 Subject: [PATCH 41/90] Add clearTimelockGuard function --- .../snapshots/abi/SaferSafes.json | 12 ++++ .../snapshots/semver-lock.json | 4 +- .../src/safe/TimelockGuard.sol | 48 ++++++++++++++-- .../test/safe/TimelockGuard.t.sol | 55 +++++++++++++++++++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index c79f6fa7929c0..1180a7afba442 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -179,6 +179,13 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "clearTimelockGuard", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -861,6 +868,11 @@ "name": "TimelockGuard_GuardNotEnabled", "type": "error" }, + { + "inputs": [], + "name": "TimelockGuard_GuardStillEnabled", + "type": "error" + }, { "inputs": [], "name": "TimelockGuard_InvalidTimelockDelay", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 67b45f0546a32..9d3588fe4e59f 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x923511040df218f2f2dee6f26f9da96f41f424bc592e8d1b74d5259473a05d1e", - "sourceCodeHash": "0x085e816c7995d12e795ec212c87dd590e6b60d0165310f1e933fce7b28b64388" + "initCodeHash": "0x3fd5de5f45b492a6bc8cad0029354f6e368bd6932d61c74ae1f44c48ea673186", + "sourceCodeHash": "0x9e2b2a28e819f6959ea63310870ccbcfa607db38b9592c428acf2c64db08e397" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ff31e9e5f11a0..1d75d3ce51119 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -149,6 +149,9 @@ abstract contract TimelockGuard is IGuard { /// @notice Error for when the contract is not at least version 1.3.0 error TimelockGuard_InvalidVersion(); + /// @notice Error for when trying to clear guard while it is still enabled + error TimelockGuard_GuardStillEnabled(); + /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. /// @param timelockDelay The timelock delay in seconds. @@ -453,6 +456,31 @@ abstract contract TimelockGuard is IGuard { _checkCombinedConfig(callingSafe); } + /// @notice Clears the timelock guard configuration for a Safe. + /// @dev Note: Clearing the configuration also cancels all pending transactions. + /// This function is intended for use when a Safe wants to permanently remove + /// the TimelockGuard configuration. Typical usage pattern: + /// 1. Safe disables the guard via GuardManager.setGuard(address(0)). + /// 2. Safe calls this clearTimelockGuard() function to remove stored configuration. + /// 3. If Safe later re-enables the guard, it must call configureTimelockGuard() again. + function clearTimelockGuard() external { + Safe callingSafe = Safe(payable(msg.sender)); + + // Check if the calling safe has configuration set + if (_safeState[callingSafe].timelockDelay == 0) { + revert TimelockGuard_GuardNotConfigured(); + } + + // Check that this guard is NOT enabled on the calling Safe + // This prevents clearing configuration while guard is still enabled + if (_isGuardEnabled(callingSafe)) { + revert TimelockGuard_GuardStillEnabled(); + } + + // Clear the configuration (guard should already be disabled by caller) + _clearTimelockGuard(callingSafe); + } + /// @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 @@ -617,16 +645,15 @@ abstract contract TimelockGuard is IGuard { // Internal Functions // //////////////////////////////////////////////////////////////// - /// @notice Internal function to disable the guard from the given Safe. - /// @dev This function is intended for use in contracts that extend TimelockGuard. - /// It clears the timelock guard configuration and cancels all pending transactions. - /// @param _targetSafe The Safe instance to disable the guard from. - function _disableAndClearGuard(Safe _targetSafe) internal virtual { + /// @notice Internal function to clear the timelock guard configuration and cancel all pending transactions. + /// @dev This function does not disable the guard - it only clears the configuration state. + /// @param _targetSafe The Safe instance to clear the configuration for. + function _clearTimelockGuard(Safe _targetSafe) internal { SafeState storage safeState = _safeState[_targetSafe]; // set the timelock delay to 0 to clear the configuration safeState.timelockDelay = 0; - // Reset the cancellation threshold, 1 is the default value for all safes. + // Reset the cancellation threshold to 0 (unconfigured state) safeState.cancellationThreshold = 0; // Get all pending transaction hashes @@ -641,6 +668,15 @@ abstract contract TimelockGuard is IGuard { safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; emit TransactionCancelled(_targetSafe, hashes[i]); } + } + + /// @notice Internal function to disable the guard from the given Safe. + /// @dev This function is intended for use in contracts that extend TimelockGuard. + /// It clears the timelock guard configuration and cancels all pending transactions. + /// @param _targetSafe The Safe instance to disable the guard from. + function _disableAndClearGuard(Safe _targetSafe) internal virtual { + // Clear the timelock guard configuration + _clearTimelockGuard(_targetSafe); // Disable the guard // Note that this will remove whichever guard is currently set on the Safe, diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 22473fccb2f1f..294f905e060fb 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -880,3 +880,58 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), maxThreshold); } } + +/// @title TimelockGuard_ClearTimelockGuard_Test +/// @notice Tests for clearTimelockGuard function +contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { + /// @notice Verifies that clearTimelockGuard successfully clears configuration after guard is disabled + function test_clearTimelockGuard_succeeds() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + + // Schedule a transaction to create pending state + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Verify transaction is pending + TimelockGuard.ScheduledTransaction memory scheduledTx = timelockGuard.scheduledTransaction(safe, dummyTx.hash); + assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Pending)); + + // Create, schedule, and execute a transaction to disable the guard + TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); + disableGuardTx.params.to = address(safeInstance.safe); + disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); + disableGuardTx.updateTransaction(); + disableGuardTx.scheduleTransaction(timelockGuard); + + // Wait for timelock delay to pass + vm.warp(block.timestamp + TIMELOCK_DELAY + 1); + + // Execute the disable guard transaction + disableGuardTx.executeTransaction(); + + // Clear the guard configuration + SafeTestLib.execTransaction( + safeInstance, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.clearTimelockGuard, ()) + ); + + // Verify configuration is cleared + assertEq(timelockGuard.timelockConfiguration(safe), 0); + assertEq(timelockGuard.cancellationThreshold(safe), 0); + + // Verify pending transaction was cancelled + scheduledTx = timelockGuard.scheduledTransaction(safe, dummyTx.hash); + assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Cancelled)); + } + + /// @notice Verifies that clearTimelockGuard reverts when guard is still enabled + function test_clearTimelockGuard_revertsWhenGuardStillEnabled() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + + // Try to clear while guard is still enabled (should revert) + vm.expectRevert(TimelockGuard.TimelockGuard_GuardStillEnabled.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.clearTimelockGuard(); + } +} From 1d4fdda1be0b90fdc2c3a9ec6a03b26664375f44 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 21:21:28 -0400 Subject: [PATCH 42/90] Add _disableGuard helper to TLG tests --- .../test/safe/TimelockGuard.t.sol | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 294f905e060fb..a110aee76a004 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -238,6 +238,21 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))) ); } + /// @notice Helper to disable guard on a Safe + function _disableGuard(SafeInstance memory _safe) internal { + // Create, schedule, and execute a transaction to disable the guard + TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); + disableGuardTx.params.to = address(safeInstance.safe); + disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); + disableGuardTx.updateTransaction(); + disableGuardTx.scheduleTransaction(timelockGuard); + + // Wait for timelock delay to pass + vm.warp(block.timestamp + TIMELOCK_DELAY + 1); + + // Execute the disable guard transaction + disableGuardTx.executeTransaction(); + } } /// @title TimelockGuard_TimelockConfiguration_Test @@ -897,18 +912,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { TimelockGuard.ScheduledTransaction memory scheduledTx = timelockGuard.scheduledTransaction(safe, dummyTx.hash); assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Pending)); - // Create, schedule, and execute a transaction to disable the guard - TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); - disableGuardTx.params.to = address(safeInstance.safe); - disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); - disableGuardTx.updateTransaction(); - disableGuardTx.scheduleTransaction(timelockGuard); - - // Wait for timelock delay to pass - vm.warp(block.timestamp + TIMELOCK_DELAY + 1); - - // Execute the disable guard transaction - disableGuardTx.executeTransaction(); + _disableGuard(safeInstance); // Clear the guard configuration SafeTestLib.execTransaction( From 220a421e94cba9466a3ccc647ed9760258c894c4 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 21:33:19 -0400 Subject: [PATCH 43/90] Limit number of transactions cancelled to 100 --- .../snapshots/abi/SaferSafes.json | 19 +++++++++++ .../snapshots/semver-lock.json | 2 +- .../src/safe/TimelockGuard.sol | 22 +++++++++--- .../test/safe/TimelockGuard.t.sol | 34 ++++++++++++++++++- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index 1180a7afba442..71d68ec3d6566 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -793,6 +793,25 @@ "name": "TransactionScheduled", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "n", + "type": "uint256" + } + ], + "name": "TransactionsNotCancelled", + "type": "event" + }, { "inputs": [], "name": "LivenessModule2_ChallengeAlreadyExists", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 9d3588fe4e59f..0c544b2bc9e2b 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,7 +208,7 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x3fd5de5f45b492a6bc8cad0029354f6e368bd6932d61c74ae1f44c48ea673186", + "initCodeHash": "0x1320736c03a77aa9c7fb916533fad2ca5b3f95b53fa39d73ee60b516721522ea", "sourceCodeHash": "0x9e2b2a28e819f6959ea63310870ccbcfa607db38b9592c428acf2c64db08e397" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 1d75d3ce51119..49a23e3c8ebbe 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -152,6 +152,11 @@ abstract contract TimelockGuard is IGuard { /// @notice Error for when trying to clear guard while it is still enabled error TimelockGuard_GuardStillEnabled(); + /// @notice Emitted when some transactions are not cancelled + /// @param safe The Safe whose transactions are not cancelled. + /// @param n The number of transactions that are not cancelled. + event TransactionsNotCancelled(Safe indexed safe, uint256 n); + /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. /// @param timelockDelay The timelock delay in seconds. @@ -659,15 +664,22 @@ abstract contract TimelockGuard is IGuard { // Get all pending transaction hashes bytes32[] memory hashes = safeState.pendingTxHashes.values(); - // Cancel all pending transactions - // It is true that iterating over a very large array can lead to gas issues, however the number of pending - // transactions is not expected to be large. If it grows to a point where this becomes an issue, then it maybe - // be necessary to manually cancel enough transactions to reduce the array size to a manageable size. - for (uint256 i = 0; i < hashes.length; i++) { + uint256 n = hashes.length <= 100 ? hashes.length : 100; + + // Cancel all pending transactions up to 100 + // It is very unlikely that there will be more than 100 pending transactions, so we can safely limit the + // number of iterations to 100 in order to prevent gas limit issues. + // If there are more than 100 pending transactions, then we emit an event to inform the user that some + // transactions were not cancelled. + for (uint256 i = 0; i < n; i++) { safeState.pendingTxHashes.remove(hashes[i]); safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; emit TransactionCancelled(_targetSafe, hashes[i]); } + + if (hashes.length > 100) { + emit TransactionsNotCancelled(_targetSafe, hashes.length - 100); + } } /// @notice Internal function to disable the guard from the given Safe. diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index a110aee76a004..efa3cf89a8aac 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -150,6 +150,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); event TransactionExecuted(Safe indexed safe, bytes32 txHash); event Message(string message); + event TransactionsNotCancelled(Safe indexed safe, uint256 n); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -238,11 +239,12 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))) ); } + /// @notice Helper to disable guard on a Safe function _disableGuard(SafeInstance memory _safe) internal { // Create, schedule, and execute a transaction to disable the guard TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); - disableGuardTx.params.to = address(safeInstance.safe); + disableGuardTx.params.to = address(_safe.safe); disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); disableGuardTx.updateTransaction(); disableGuardTx.scheduleTransaction(timelockGuard); @@ -928,6 +930,36 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Cancelled)); } + function test_clearTimelockGuard_moreThan100PendingTransactions_succeeds() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + + // Schedule a transaction to create pending state + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + // Schedule more than 100 transactions + for (uint256 i = 0; i < 150; i++) { + dummyTx.setNonce(dummyTx.nonce + 1); + dummyTx.updateTransaction(); + dummyTx.scheduleTransaction(timelockGuard); + } + + _disableGuard(safeInstance); + + // Clear the guard configuration + vm.prank(address(safeInstance.safe)); + vm.expectEmit(true, true, true, true); + emit TransactionsNotCancelled(safeInstance.safe, 50); + timelockGuard.clearTimelockGuard(); + + // Ensure that the call is below a safe gas limit. The EIP-7825 limit is 16,777,216, so 12M is a safe limit. + assertLt(vm.lastCallGas().gasTotalUsed, 12_000_000); + + // Verify configuration is cleared + assertEq(timelockGuard.timelockConfiguration(safe), 0); + assertEq(timelockGuard.cancellationThreshold(safe), 0); + } + /// @notice Verifies that clearTimelockGuard reverts when guard is still enabled function test_clearTimelockGuard_revertsWhenGuardStillEnabled() external { // First configure the guard From 275f9508c1dab2870381c1dc280ddb59ab36b175 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 21:50:24 -0400 Subject: [PATCH 44/90] Revert "Remove interfaces for abstract contracts" This reverts commit bd032889b639b35b8f512f941de3a924699fe44c. --- .../interfaces/safe/ILivenessModule2.sol | 48 ++++++++++ .../interfaces/safe/ITimelockGuard.sol | 91 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol create mode 100644 packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol new file mode 100644 index 0000000000000..921d1a794c776 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title ILivenessModule2 +/// @notice Interface for LivenessModule2, a singleton module for challenge-based ownership transfer +interface ILivenessModule2 { + /// @notice Configuration for a Safe's liveness module + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } + + /// @notice Returns the configuration for a Safe + /// @return livenessResponsePeriod The response period + /// @return fallbackOwner The fallback owner address + function livenessSafeConfiguration(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner); + + /// @notice Returns the challenge start time for a Safe (0 if no challenge) + /// @return The challenge start timestamp + function challengeStartTime(address) external view returns (uint256); + + /// @notice Semantic version + /// @return version The contract version + function version() external view returns (string memory); + + /// @notice Configures the module for a Safe that has already enabled it + /// @param _config The configuration parameters for the module + function configureLivenessModule(ModuleConfig memory _config) external; + + /// @notice Clears the module configuration for a Safe + function clearLivenessModule() external; + + /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge, or 0 if not + /// @param _safe The Safe address to query + /// @return The challenge end timestamp, or 0 if no challenge + function getChallengePeriodEnd(address _safe) external view returns (uint256); + + /// @notice Challenges an enabled safe + /// @param _safe The Safe to challenge + function challenge(address _safe) external; + + /// @notice Responds to a challenge for an enabled safe, canceling it + function respond() external; + + /// @notice Removes all current owners from an enabled safe and appoints fallback as sole owner + /// @param _safe The Safe to transfer ownership of + function changeOwnershipToFallback(address _safe) external; +} diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol new file mode 100644 index 0000000000000..984309a87c45c --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +library Enum { + type Operation is uint8; +} + +interface ITimelockGuard { + enum TransactionState { + NotScheduled, + Pending, + Cancelled, + Executed + } + struct ScheduledTransaction { + uint256 executionTime; + TransactionState state; + ExecTransactionParams params; + } + + struct ExecTransactionParams { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + } + + error TimelockGuard_GuardNotConfigured(); + error TimelockGuard_GuardNotEnabled(); + error TimelockGuard_GuardStillEnabled(); + error TimelockGuard_InvalidTimelockDelay(); + error TimelockGuard_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); + 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 signCancellation(bytes32 _txHash) external; + function cancellationThreshold(address _safe) external view returns (uint256); + 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; + function checkAfterExecution(bytes32, bool) external; + function configureTimelockGuard(uint256 _timelockDelay) external; + function scheduledTransaction( + address _safe, + bytes32 _txHash + ) + external + view + returns (ScheduledTransaction memory); + function safeConfigs(address) external view returns (uint256 timelockDelay); + function scheduleTransaction( + address _safe, + uint256 _nonce, + ExecTransactionParams memory _params, + bytes memory _signatures + ) + external; + function timelockConfiguration(address _safe) external view returns (uint256 timelockDelay); + function maxCancellationThreshold(address _safe) external view returns (uint256); + function pendingTransactions(address _safe) + external + view + returns (ScheduledTransaction[] memory); +} From e4b44577131c76ef5b7b471875901ef1531ebab0 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 22:09:14 -0400 Subject: [PATCH 45/90] Move livenessModule2 address into TestUtils Reduces diff a bit --- .../test/safe/LivenessModule2.t.sol | 50 +++++++++---------- .../test/safe/SaferSafes.t.sol | 18 ++++--- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index a45e25ac7b420..5204d05c91a84 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -13,12 +13,13 @@ import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; /// @title LivenessModule2_TestUtils /// @notice Reusable helper methods for LivenessModule2 tests. abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { + LivenessModule2 livenessModule2; + /// @notice Helper to enable the LivenessModule2 for a Safe function _enableModule( SafeInstance memory _safe, uint256 _period, - address _fallback, - SaferSafes _livenessModule2 + address _fallback ) internal { @@ -26,7 +27,7 @@ abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); SafeTestLib.execTransaction( _safe, - address(_livenessModule2), + address(livenessModule2), 0, abi.encodeCall(LivenessModule2.configureLivenessModule, (config)), Enum.Operation.Call @@ -34,20 +35,20 @@ abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { } /// @notice Helper to disable the LivenessModule2 for a Safe - function _disableModule(SafeInstance memory _safe, SaferSafes _livenessModule2) internal { + function _disableModule(SafeInstance memory _safe) internal { // First disable the module at the Safe level SafeTestLib.execTransaction( _safe, address(_safe.safe), 0, - abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(_livenessModule2))), + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), Enum.Operation.Call ); // Then clear the module configuration SafeTestLib.execTransaction( _safe, - address(_livenessModule2), + address(livenessModule2), 0, abi.encodeCall(LivenessModule2.clearLivenessModule, ()), Enum.Operation.Call @@ -55,9 +56,9 @@ abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { } /// @notice Helper to respond to a challenge from a Safe - function _respondToChallenge(SafeInstance memory _safe, SaferSafes _livenessModule2) internal { + function _respondToChallenge(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, address(_livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call ); } @@ -87,7 +88,6 @@ contract LivenessModule2_TestInit is LivenessModule2_TestUtils { uint256 constant NUM_OWNERS = 5; uint256 constant THRESHOLD = 3; - SaferSafes livenessModule2; SafeInstance safeInstance; address fallbackOwner; address[] owners; @@ -122,7 +122,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni vm.expectEmit(true, true, true, true); emit ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period, CHALLENGE_PERIOD); @@ -149,9 +149,9 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni address fallback3 = makeAddr("fallback3"); // Configure module for each safe - _enableModule(safe1, 1 days, fallback1, livenessModule2); - _enableModule(safe2, 2 days, fallback2, livenessModule2); - _enableModule(safe3, 3 days, fallback3, livenessModule2); + _enableModule(safe1, 1 days, fallback1); + _enableModule(safe2, 2 days, fallback2); + _enableModule(safe3, 3 days, fallback3); // Verify each safe has independent configuration (uint256 period1, address fb1) = livenessModule2.livenessSafeConfiguration(address(safe1.safe)); @@ -201,7 +201,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni function test_configureLivenessModule_cancelsExistingChallenge_succeeds() external { // First configure the module - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Start a challenge vm.prank(fallbackOwner); @@ -228,7 +228,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni } function test_clear_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // First disable the module at the Safe level SafeTestLib.execTransaction( @@ -263,7 +263,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni } function test_clear_moduleStillEnabled_reverts() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Try to clear while module is still enabled (should revert) vm.expectRevert(LivenessModule2.LivenessModule2_ModuleStillEnabled.selector); @@ -277,7 +277,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function setUp() public override { super.setUp(); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); } function test_challenge_succeeds() external { @@ -325,7 +325,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeTestLib.enableModule(disabledSafe, address(livenessModule2)); // Then configure - _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner); // Now disable the module at Safe level (but keep config) SafeTestLib.execTransaction( @@ -351,7 +351,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.expectEmit(true, true, true, true); emit ChallengeCancelled(address(safeInstance.safe)); - _respondToChallenge(safeInstance, livenessModule2); + _respondToChallenge(safeInstance); // Verify challenge is cancelled uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); @@ -419,7 +419,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeTestLib.enableModule(configuredSafe, address(livenessModule2)); // Configure the module (this sets the configuration) - _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner); // Now disable the module at Safe level (but keep config) SafeTestLib.execTransaction( @@ -454,7 +454,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); // After enabling - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); (uint256 period2, address fbOwner2) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period2, CHALLENGE_PERIOD); assertEq(fbOwner2, fallbackOwner); @@ -462,7 +462,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit } function test_getChallengePeriodEnd_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // No challenge assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); @@ -473,11 +473,7 @@ contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); // After cancellation - _respondToChallenge(safeInstance, livenessModule2); + _respondToChallenge(safeInstance); assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); } - - function test_version_succeeds() external view { - assertTrue(bytes(livenessModule2.version()).length > 0); - } } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index c10f42cd1bed0..764245c286459 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -31,7 +31,6 @@ contract SaferSafes_TestInit is LivenessModule2_TestUtils { uint256 constant CHALLENGE_PERIOD = 7 days; SaferSafes saferSafes; - SaferSafes livenessModule2; SafeInstance safeInstance; address fallbackOwner; address[] owners; @@ -42,7 +41,7 @@ contract SaferSafes_TestInit is LivenessModule2_TestUtils { // Deploy the SaferSafes contract saferSafes = new SaferSafes(); - livenessModule2 = saferSafes; + livenessModule2 = LivenessModule2(address(saferSafes)); // Create Safe owners (address[] memory _owners, uint256[] memory _keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); @@ -64,6 +63,9 @@ contract SaferSafes_TestInit is LivenessModule2_TestUtils { /// @title SaferSafes_Uncategorized_Test /// @notice Tests for SaferSafes configuration functionality. contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { + function test_version_succeeds() external view { + assertTrue(bytes(saferSafes.version()).length > 0); + } /// @notice Test successful configuration when liveness response period is at least 2x timelock delay. function test_configure_livenessModuleFirst_succeeds() public { uint256 timelockDelay = 7 days; @@ -163,7 +165,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { function setUp() public override { super.setUp(); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // enable the guard SafeTestLib.execTransaction( @@ -219,7 +221,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { function test_changeOwnershipToFallback_withOtherModules_succeeds() external { // First disable the module, because we want it to be in the middle of the Safe's list // of modules. - _disableModule(safeInstance, livenessModule2); + _disableModule(safeInstance); // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle up to 100 // modules. @@ -229,7 +231,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Enable the LivenessModule2 on the Safe SafeTestLib.enableModule(safeInstance, address(livenessModule2)); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Enable 1 more module to bring us to 100 modules. SafeTestLib.enableModule(safeInstance, address(makeAddr("module100"))); @@ -261,7 +263,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Create a mock guard address dummyGuard = makeAddr("dummyGuard"); vm.mockCall( @@ -350,7 +352,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Start a challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); @@ -375,7 +377,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { } function test_changeOwnershipToFallback_canRechallenge_succeeds() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner, livenessModule2); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Start and execute first challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); From 4bf722e9d7d19bc0a99e8ce4c8714f9b4c05d43a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 22:12:39 -0400 Subject: [PATCH 46/90] Reduce diff somewhat --- .../contracts-bedrock/test/safe/LivenessModule2.t.sol | 10 ++-------- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 1 + 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 5204d05c91a84..d41ea8e325533 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -16,13 +16,7 @@ abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { LivenessModule2 livenessModule2; /// @notice Helper to enable the LivenessModule2 for a Safe - function _enableModule( - SafeInstance memory _safe, - uint256 _period, - address _fallback - ) - internal - { + function _enableModule(SafeInstance memory _safe, uint256 _period, address _fallback) internal { LivenessModule2.ModuleConfig memory config = LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); SafeTestLib.execTransaction( @@ -434,7 +428,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(configuredSafe.safe)); assertTrue(period > 0); // Configuration exists assertTrue(fbOwner != address(0)); // Configuration exists - assertFalse(ModuleManager(configuredSafe.safe).isModuleEnabled(address(livenessModule2))); // Module not enabled + assertFalse(configuredSafe.safe.isModuleEnabled(address(livenessModule2))); // Module not enabled // Now respond() should revert because module is not enabled vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 764245c286459..c9b6daf98a926 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -66,6 +66,7 @@ contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { function test_version_succeeds() external view { assertTrue(bytes(saferSafes.version()).length > 0); } + /// @notice Test successful configuration when liveness response period is at least 2x timelock delay. function test_configure_livenessModuleFirst_succeeds() public { uint256 timelockDelay = 7 days; From 67f13d2114a6ce50d3ddf6af86762d6e6b88ea8f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 22:22:12 -0400 Subject: [PATCH 47/90] Remove unused arg --- .../contracts-bedrock/test/safe/SaferSafes.t.sol | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index c9b6daf98a926..2996cfcb57ff4 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.15; import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { TimelockGuard } from "src/safe/TimelockGuard.sol"; @@ -178,7 +179,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { ); } - function _assertOwnershipChanged(address /* _safe */ ) internal view { + function _assertOwnershipChanged() internal view { // Verify ownership changed address[] memory newOwners = safeInstance.safe.getOwners(); assertEq(newOwners.length, 1); @@ -199,6 +200,9 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Ensure TimelockGuard properties are cleared assertEq(timelockGuard.timelockConfiguration(safeInstance.safe), 0); assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 0); + + // Ensure all pending transactions are cancelled + assertEq(timelockGuard.pendingTransactions(Safe(payable(address(safeInstance.safe)))).length, 0); } function test_changeOwnershipToFallback_succeeds() external { @@ -216,7 +220,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { vm.prank(fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - _assertOwnershipChanged(address(safeInstance.safe)); + _assertOwnershipChanged(); } function test_changeOwnershipToFallback_withOtherModules_succeeds() external { @@ -260,7 +264,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); assertEq(modules.length, 99); - _assertOwnershipChanged(address(safeInstance.safe)); + _assertOwnershipChanged(); } function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { @@ -301,7 +305,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); // These checks include ensuring that the guard is deactivated - _assertOwnershipChanged(address(safeInstance.safe)); + _assertOwnershipChanged(); } function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { From f62065a6850a3a917499ccf2c51c675c62c82f36 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 22:32:14 -0400 Subject: [PATCH 48/90] Update packages/contracts-bedrock/src/safe/TimelockGuard.sol Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 49a23e3c8ebbe..0b0163eff0d56 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -153,9 +153,11 @@ abstract contract TimelockGuard is IGuard { error TimelockGuard_GuardStillEnabled(); /// @notice Emitted when some transactions are not cancelled + /// @param safe The Safe whose transactions are not cancelled. - /// @param n The number of transactions that are not cancelled. - event TransactionsNotCancelled(Safe indexed safe, uint256 n); + /// @param uncancelledCount The number of transactions that are not cancelled. + event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); + /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. From 415d98f05f6691e174fefd60a182ed1e0b6acbf2 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 15 Oct 2025 22:37:42 -0400 Subject: [PATCH 49/90] Fix iface --- packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol | 1 + packages/contracts-bedrock/src/safe/TimelockGuard.sol | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 984309a87c45c..8d462136ed2b8 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -47,6 +47,7 @@ interface ITimelockGuard { event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executionTime); event TransactionExecuted(address indexed safe, bytes32 txHash); event Message(string message); + event TransactionsNotCancelled(address indexed safe, uint256 uncancelledCount); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function signCancellation(bytes32 _txHash) external; diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 0b0163eff0d56..e71da5dd226ae 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -158,7 +158,6 @@ abstract contract TimelockGuard is IGuard { /// @param uncancelledCount The number of transactions that are not cancelled. event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); - /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. /// @param timelockDelay The timelock delay in seconds. From 9b0e831d4378711a37a2a9af5dae4f3c4c2cca95 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 09:48:22 -0400 Subject: [PATCH 50/90] update abi for iface fix --- packages/contracts-bedrock/snapshots/abi/SaferSafes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index 71d68ec3d6566..827478086875b 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -805,7 +805,7 @@ { "indexed": false, "internalType": "uint256", - "name": "n", + "name": "uncancelledCount", "type": "uint256" } ], From 4ee99aabf4abb32306a9c030fa71bf7508a686e3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 09:49:09 -0400 Subject: [PATCH 51/90] Do not clear or disable the module during ownership transfer --- .../snapshots/semver-lock.json | 2 +- .../src/safe/LivenessModule2.sol | 56 ++--------------- .../test/safe/SaferSafes.t.sol | 62 ------------------- 3 files changed, 6 insertions(+), 114 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 0c544b2bc9e2b..27d4312170127 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,7 +208,7 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x1320736c03a77aa9c7fb916533fad2ca5b3f95b53fa39d73ee60b516721522ea", + "initCodeHash": "0xff0600b85c0e84291868af48e31ad01f7c8ff7c349f76186721370598aeb02fe", "sourceCodeHash": "0x9e2b2a28e819f6959ea63310870ccbcfa607db38b9592c428acf2c64db08e397" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 71845076eba39..8ebe24637538f 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -170,8 +170,11 @@ abstract contract LivenessModule2 { // This prevents clearing configuration while module is still enabled _assertModuleNotEnabled(msg.sender); - // Clear the configuration and any active challenge - _clearLivenessModule(Safe(payable(msg.sender))); + // Erase the configuration data for this safe + delete livenessSafeConfiguration[msg.sender]; + // Also clear any active challenge + _cancelChallenge(msg.sender); + emit ModuleCleared(msg.sender); } /// @notice Challenges an enabled safe. @@ -292,9 +295,6 @@ abstract contract LivenessModule2 { // Disable and clear this guard. // Removes whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. _disableAndClearGuard(targetSafe); - - // Disable this module from the Safe - _disableAndClearThisModule(targetSafe); } /// @notice Asserts that the module is configured for the given Safe. @@ -334,55 +334,9 @@ abstract contract LivenessModule2 { emit ChallengeCancelled(_safe); } - /// @notice Internal function to clear the liveness module configuration and any active challenge. - /// @param _safe The Safe instance to clear the configuration for. - function _clearLivenessModule(Safe _safe) internal { - // Erase the configuration data for this safe - delete livenessSafeConfiguration[address(_safe)]; - // Also clear any active challenge - _cancelChallenge(address(_safe)); - emit ModuleCleared(address(_safe)); - } - /// @notice Internal function to disable this guard from the given Safe. /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another /// guard is enabled. /// @param _targetSafe The Safe instance to disable this guard from. function _disableAndClearGuard(Safe _targetSafe) internal virtual; - - /// @notice Internal function to disable this module from the given Safe. - /// @param _targetSafe The Safe instance to disable this module from. - function _disableAndClearThisModule(Safe _targetSafe) internal { - // Get current modules - // This might not work if you have more than 100 modules, but what are you even doing if that's the case? - (address[] memory modules,) = _targetSafe.getModulesPaginated(SENTINEL_MODULE, 100); - - // Find the index of this module - bool moduleFound = false; - uint256 moduleIndex = 0; - for (uint256 i = 0; i < modules.length; i++) { - if (modules[i] == address(this)) { - moduleIndex = i; - moduleFound = true; - break; - } - } - - if (!moduleFound) return; - - // If the module is the first in the list, then the previous module is the sentinel. - // Otherwise, the previous module is the module before in the array. - address prevModule = moduleIndex == 0 ? SENTINEL_MODULE : modules[moduleIndex - 1]; - - // Disable the module - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(ModuleManager.disableModule, (prevModule, address(this))) - }); - - // Erase the configuration data for this safe - _clearLivenessModule(_targetSafe); - } } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 2996cfcb57ff4..0739a8d2ddc53 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -190,9 +190,6 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertEq(challengeEndTime, 0); - // Verify module is disabled - assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); - // Verify guard is deactivated assertEq(_getGuard(safeInstance), address(0)); TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); @@ -223,50 +220,6 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { _assertOwnershipChanged(); } - function test_changeOwnershipToFallback_withOtherModules_succeeds() external { - // First disable the module, because we want it to be in the middle of the Safe's list - // of modules. - _disableModule(safeInstance); - - // Enable some extra modules on the Safe before the LivenessModule2 to ensure that we can handle up to 100 - // modules. - for (uint256 i = 0; i < 98; i++) { - SafeTestLib.enableModule(safeInstance, address(makeAddr(string(abi.encodePacked("module", i))))); - } - - // Enable the LivenessModule2 on the Safe - SafeTestLib.enableModule(safeInstance, address(livenessModule2)); - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Enable 1 more module to bring us to 100 modules. - SafeTestLib.enableModule(safeInstance, address(makeAddr("module100"))); - - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - - // Warp past challenge period - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - vm.expectEmit(true, true, true, true); - emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); - - vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Ensure that the call is below a safe gas limit. The EIP-7825 limit is 16,777,216, so 12M is a safe limit. - assertLt(vm.lastCallGas().gasTotalUsed, 12_000_000); - - // Verify module is disabled - assertFalse(ModuleManager(safeInstance.safe).isModuleEnabled(address(livenessModule2))); - // Verify extra modules are still enabled - (address[] memory modules,) = ModuleManager(safeInstance.safe).getModulesPaginated(address(1), 1000); - assertEq(modules.length, 99); - - _assertOwnershipChanged(); - } - function test_changeOwnershipToFallback_withOtherGuard_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Create a mock guard @@ -391,21 +344,6 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { vm.prank(fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - // Re-enable the module - vm.prank(fallbackOwner); - safeInstance.safe.execTransaction( - address(safeInstance.safe), - 0, - abi.encodeCall(ModuleManager.enableModule, (address(livenessModule2))), - Enum.Operation.Call, - 0, - 0, - 0, - address(0), - payable(address(0)), - abi.encodePacked(bytes32(uint256(uint160(fallbackOwner))), bytes32(0), uint8(1)) - ); - // Re-configure the module vm.prank(address(safeInstance.safe)); livenessModule2.configureLivenessModule( From 75430d447e27a95b2aef98af3be82e303c73f986 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 09:56:23 -0400 Subject: [PATCH 52/90] Fix inaccurate comment on _disableAndClearGuard --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 8ebe24637538f..c1aa881ffb6dc 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -335,8 +335,9 @@ abstract contract LivenessModule2 { } /// @notice Internal function to disable this guard from the given Safe. - /// @dev Only disables the guard if it is enabled, otherwise does nothing in case another - /// guard is enabled. + /// @dev Disables whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. + /// This is intentional, as it is possible that the guard itself was the cause of the liveness failure + /// which resulted in the transfer of ownership to the fallback owner. /// @param _targetSafe The Safe instance to disable this guard from. function _disableAndClearGuard(Safe _targetSafe) internal virtual; } From b459c34cf21ff9de2e2c9dfa41e1ce866d4b4d3e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 10:01:51 -0400 Subject: [PATCH 53/90] Further improve comment --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index c1aa881ffb6dc..8e59bdb885fae 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -334,10 +334,10 @@ abstract contract LivenessModule2 { emit ChallengeCancelled(_safe); } - /// @notice Internal function to disable this guard from the given Safe. + /// @notice Internal function to disable the guard and clear the configuration from the given Safe. /// @dev Disables whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. /// This is intentional, as it is possible that the guard itself was the cause of the liveness failure /// which resulted in the transfer of ownership to the fallback owner. - /// @param _targetSafe The Safe instance to disable this guard from. + /// @param _targetSafe The Safe instance to disable the guard and clear the configuration from. function _disableAndClearGuard(Safe _targetSafe) internal virtual; } From da19fe85879d8d2a0e66eb88cec221dcc39f902d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 10:15:08 -0400 Subject: [PATCH 54/90] remove unused import --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 8e59bdb885fae..c1154bb03b609 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.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 { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; -import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner From 491f48427c5b6509b2bd41afceec5f17e29eed5d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 11:10:45 -0400 Subject: [PATCH 55/90] fix test name --- 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 efa3cf89a8aac..364a793d575c1 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -961,7 +961,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { } /// @notice Verifies that clearTimelockGuard reverts when guard is still enabled - function test_clearTimelockGuard_revertsWhenGuardStillEnabled() external { + function test_clearTimelockGuard_guardStillEnabled_reverts() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); From 56b439db7e7c1c9eb7241e4f087e97a43cac0f95 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:25:35 -0400 Subject: [PATCH 56/90] Do not clear guard during changeOwnershipToFallback --- .../snapshots/semver-lock.json | 4 +-- .../src/safe/LivenessModule2.sol | 25 +++++++++---------- .../contracts-bedrock/src/safe/SaferSafes.sol | 7 ------ .../src/safe/TimelockGuard.sol | 21 ---------------- 4 files changed, 14 insertions(+), 43 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 000301c6ee087..a7eb869be7189 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0xff0600b85c0e84291868af48e31ad01f7c8ff7c349f76186721370598aeb02fe", - "sourceCodeHash": "0x9e2b2a28e819f6959ea63310870ccbcfa607db38b9592c428acf2c64db08e397" + "initCodeHash": "0x1aa0383f638d6acf8e34b85e29cceedee428630f457605180ab44ad7a70e4c4b", + "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index c1154bb03b609..8f6973259531f 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.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 { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; /// @title LivenessModule2 /// @notice This module allows challenge-based ownership transfer to a fallback owner @@ -288,12 +289,17 @@ abstract contract LivenessModule2 { delete challengeStartTime[_safe]; emit ChallengeSucceeded(_safe, livenessSafeConfiguration[_safe].fallbackOwner); - // Now we will disable the guard and module from the Safe, so that the Safe is set to a - // minimal state, which is as simple as possible for the fallback owner to reason about. - - // Disable and clear this guard. - // Removes whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. - _disableAndClearGuard(targetSafe); + // Disable the guard + // Note that this will remove whichever guard is currently set on the Safe, + // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard + // itself was the cause of the liveness failure which resulted in the transfer of ownership to + // the fallback owner. + targetSafe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(GuardManager.setGuard, (address(0))) + }); } /// @notice Asserts that the module is configured for the given Safe. @@ -332,11 +338,4 @@ abstract contract LivenessModule2 { delete challengeStartTime[_safe]; emit ChallengeCancelled(_safe); } - - /// @notice Internal function to disable the guard and clear the configuration from the given Safe. - /// @dev Disables whichever guard is currently set on the Safe, even if it is not the SaferSafes guard. - /// This is intentional, as it is possible that the guard itself was the cause of the liveness failure - /// which resulted in the transfer of ownership to the fallback owner. - /// @param _targetSafe The Safe instance to disable the guard and clear the configuration from. - function _disableAndClearGuard(Safe _targetSafe) internal virtual; } diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 9bb0806bb6b22..53b64c8a73a65 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -60,11 +60,4 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { revert SaferSafes_InsufficientLivenessResponsePeriod(); } } - - /// @notice Internal function to disable the guard from the given Safe. - /// @dev This function is a wrapper that calls the parent TimelockGuard implementation. - /// @param _targetSafe The Safe instance to disable the guard from. - function _disableAndClearGuard(Safe _targetSafe) internal override(LivenessModule2, TimelockGuard) { - TimelockGuard._disableAndClearGuard(_targetSafe); - } } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index e71da5dd226ae..68b86c5a12c56 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -682,25 +682,4 @@ abstract contract TimelockGuard is IGuard { emit TransactionsNotCancelled(_targetSafe, hashes.length - 100); } } - - /// @notice Internal function to disable the guard from the given Safe. - /// @dev This function is intended for use in contracts that extend TimelockGuard. - /// It clears the timelock guard configuration and cancels all pending transactions. - /// @param _targetSafe The Safe instance to disable the guard from. - function _disableAndClearGuard(Safe _targetSafe) internal virtual { - // Clear the timelock guard configuration - _clearTimelockGuard(_targetSafe); - - // Disable the guard - // Note that this will remove whichever guard is currently set on the Safe, - // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard - // itself was the cause of the liveness failure which resulted in the transfer of ownership to - // the fallback owner. - _targetSafe.execTransactionFromModule({ - to: address(_targetSafe), - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(GuardManager.setGuard, (address(0))) - }); - } } From 4f499e8c6f98e043247497d96b4c26285dfa7a40 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:35:11 -0400 Subject: [PATCH 57/90] Remove unused SENTINEL_MODULE var --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 8f6973259531f..657906fe4982d 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -35,9 +35,6 @@ abstract contract LivenessModule2 { /// @notice Reserved address used as previous owner to the first owner in a Safe. address internal constant SENTINEL_OWNER = address(0x1); - /// @notice Reserved address used as previous module to the first module in a Safe. - address internal constant SENTINEL_MODULE = address(0x1); - /// @notice Error for when module is not enabled for the Safe. error LivenessModule2_ModuleNotEnabled(); From d266d12dbc5af68b192c8898b7c97d772a8954a3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:37:00 -0400 Subject: [PATCH 58/90] Remove dangling comment --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 68b86c5a12c56..87661ff6c6884 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -152,8 +152,6 @@ abstract contract TimelockGuard is IGuard { /// @notice Error for when trying to clear guard while it is still enabled error TimelockGuard_GuardStillEnabled(); - /// @notice Emitted when some transactions are not cancelled - /// @param safe The Safe whose transactions are not cancelled. /// @param uncancelledCount The number of transactions that are not cancelled. event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); From 9f50cdca7dfec7a2d3fd9add039d3f3dbc72e128 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:37:25 -0400 Subject: [PATCH 59/90] Revert "Remove dangling comment" This reverts commit d266d12dbc5af68b192c8898b7c97d772a8954a3. --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 87661ff6c6884..68b86c5a12c56 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -152,6 +152,8 @@ abstract contract TimelockGuard is IGuard { /// @notice Error for when trying to clear guard while it is still enabled error TimelockGuard_GuardStillEnabled(); + /// @notice Emitted when some transactions are not cancelled + /// @param safe The Safe whose transactions are not cancelled. /// @param uncancelledCount The number of transactions that are not cancelled. event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); From 3ce855f4dd2df57877fe6f5d5817eddfda3c4bd4 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:38:00 -0400 Subject: [PATCH 60/90] Fix whitespace --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 68b86c5a12c56..46c534599eeff 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -152,8 +152,7 @@ abstract contract TimelockGuard is IGuard { /// @notice Error for when trying to clear guard while it is still enabled error TimelockGuard_GuardStillEnabled(); - /// @notice Emitted when some transactions are not cancelled - + /// @notice Emitted when some transactions are not cancelled. /// @param safe The Safe whose transactions are not cancelled. /// @param uncancelledCount The number of transactions that are not cancelled. event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); From af77e1b4f05f86fca1997949ed4e2486074ce842 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:44:02 -0400 Subject: [PATCH 61/90] remove unnecessary internal _clearTimelockGuard function It's no longer reused in the change ownership call. --- .../src/safe/TimelockGuard.sol | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 46c534599eeff..5f46753cdc7da 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -470,9 +470,10 @@ abstract contract TimelockGuard is IGuard { /// 3. If Safe later re-enables the guard, it must call configureTimelockGuard() again. function clearTimelockGuard() external { Safe callingSafe = Safe(payable(msg.sender)); + SafeState storage safeState = _safeState[callingSafe]; // Check if the calling safe has configuration set - if (_safeState[callingSafe].timelockDelay == 0) { + if (safeState.timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -483,7 +484,31 @@ abstract contract TimelockGuard is IGuard { } // Clear the configuration (guard should already be disabled by caller) - _clearTimelockGuard(callingSafe); + // set the timelock delay to 0 to clear the configuration + safeState.timelockDelay = 0; + + // Reset the cancellation threshold to 0 (unconfigured state) + safeState.cancellationThreshold = 0; + + // Get all pending transaction hashes + bytes32[] memory hashes = safeState.pendingTxHashes.values(); + + uint256 n = hashes.length <= 100 ? hashes.length : 100; + + // Cancel all pending transactions up to 100 + // It is very unlikely that there will be more than 100 pending transactions, so we can safely limit the + // number of iterations to 100 in order to prevent gas limit issues. + // If there are more than 100 pending transactions, then we emit an event to inform the user that some + // transactions were not cancelled. + for (uint256 i = 0; i < n; i++) { + safeState.pendingTxHashes.remove(hashes[i]); + safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; + emit TransactionCancelled(callingSafe, hashes[i]); + } + + if (hashes.length > 100) { + emit TransactionsNotCancelled(callingSafe, hashes.length - 100); + } } /// @notice Schedule a transaction for execution after the timelock delay. @@ -645,40 +670,7 @@ abstract contract TimelockGuard is IGuard { function signCancellation(bytes32) public { emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } - - //////////////////////////////////////////////////////////////// - // Internal Functions // - //////////////////////////////////////////////////////////////// - - /// @notice Internal function to clear the timelock guard configuration and cancel all pending transactions. - /// @dev This function does not disable the guard - it only clears the configuration state. - /// @param _targetSafe The Safe instance to clear the configuration for. function _clearTimelockGuard(Safe _targetSafe) internal { - SafeState storage safeState = _safeState[_targetSafe]; - // set the timelock delay to 0 to clear the configuration - safeState.timelockDelay = 0; - - // Reset the cancellation threshold to 0 (unconfigured state) - safeState.cancellationThreshold = 0; - - // Get all pending transaction hashes - bytes32[] memory hashes = safeState.pendingTxHashes.values(); - - uint256 n = hashes.length <= 100 ? hashes.length : 100; - // Cancel all pending transactions up to 100 - // It is very unlikely that there will be more than 100 pending transactions, so we can safely limit the - // number of iterations to 100 in order to prevent gas limit issues. - // If there are more than 100 pending transactions, then we emit an event to inform the user that some - // transactions were not cancelled. - for (uint256 i = 0; i < n; i++) { - safeState.pendingTxHashes.remove(hashes[i]); - safeState.scheduledTransactions[hashes[i]].state = TransactionState.Cancelled; - emit TransactionCancelled(_targetSafe, hashes[i]); - } - - if (hashes.length > 100) { - emit TransactionsNotCancelled(_targetSafe, hashes.length - 100); - } } } From 39434fc489831a75dffa1f2d017c33ccea658c15 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 09:33:58 -0400 Subject: [PATCH 62/90] Address feedback --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 2 +- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 3 --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 7 ------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 657906fe4982d..1feb559568913 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -159,7 +159,7 @@ abstract contract LivenessModule2 { /// 3. If Safe later re-enables the module, it must call configureLivenessModule() again. /// Never calling clearLivenessModule() after disabling keeps configuration data persistent /// for potential future re-enabling. - function clearLivenessModule() public { + function clearLivenessModule() external { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5f46753cdc7da..a6ca4ed7e6384 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -670,7 +670,4 @@ abstract contract TimelockGuard is IGuard { function signCancellation(bytes32) public { emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } - function _clearTimelockGuard(Safe _targetSafe) internal { - - } } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 9929bb5967242..703867e16e3a7 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -193,13 +193,6 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Verify guard is deactivated assertEq(_getGuard(safeInstance), address(0)); TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); - - // Ensure TimelockGuard properties are cleared - assertEq(timelockGuard.timelockConfiguration(safeInstance.safe), 0); - assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 0); - - // Ensure all pending transactions are cancelled - assertEq(timelockGuard.pendingTransactions(Safe(payable(address(safeInstance.safe)))).length, 0); } function test_changeOwnershipToFallback_succeeds() external { From 3f2595b4d62c6fcdc535595ba90d0669ed3da6a7 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 09:51:15 -0400 Subject: [PATCH 63/90] Add missing assertion --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 6a62ed0fa10fe..1878182254f29 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -955,6 +955,9 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { // Ensure that the call is below a safe gas limit. The EIP-7825 limit is 16,777,216, so 12M is a safe limit. assertLt(vm.lastCallGas().gasTotalUsed, 12_000_000); + // Ensure the remaining pending transactions are 50 as expected + assertEq(timelockGuard.pendingTransactions(Safe(payable(address(safeInstance.safe)))).length, 50); + // Verify configuration is cleared assertEq(timelockGuard.timelockConfiguration(safe), 0); assertEq(timelockGuard.cancellationThreshold(safe), 0); From 697710972c4bc47db5b832dc0f9fd620c8a2fae8 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 09:52:03 -0400 Subject: [PATCH 64/90] Move guard slot into constants --- packages/contracts-bedrock/src/libraries/Constants.sol | 4 ++++ packages/contracts-bedrock/src/safe/LivenessModule.sol | 8 ++++---- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 5 ++--- .../contracts-bedrock/test/safe/LivenessModule2.t.sol | 5 ++--- .../contracts-bedrock/test/scripts/DeployOwnership.t.sol | 3 +-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/contracts-bedrock/src/libraries/Constants.sol b/packages/contracts-bedrock/src/libraries/Constants.sol index 6dcf3611956af..50ad716697486 100644 --- a/packages/contracts-bedrock/src/libraries/Constants.sol +++ b/packages/contracts-bedrock/src/libraries/Constants.sol @@ -31,6 +31,10 @@ library Constants { /// @dev `bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)` bytes32 internal constant PROXY_OWNER_ADDRESS = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + /// @notice The storage slot that holds the guard address in Safe contracts. + /// @dev `keccak256("guard_manager.guard.address")` + bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + /// @notice The address that represents ether when dealing with ERC20 token addresses. address internal constant ETHER = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; diff --git a/packages/contracts-bedrock/src/safe/LivenessModule.sol b/packages/contracts-bedrock/src/safe/LivenessModule.sol index 7645f11903f47..1af049760ef69 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule.sol @@ -12,6 +12,9 @@ import { LivenessGuard } from "src/safe/LivenessGuard.sol"; // Interfaces import { ISemver } from "interfaces/universal/ISemver.sol"; +// Libraries +import { Constants } from "src/libraries/Constants.sol"; + /// @title LivenessModule /// @notice This module is intended to be used in conjunction with the LivenessGuard. In the event /// that an owner of the safe is not recorded by the guard during the liveness interval, @@ -53,9 +56,6 @@ contract LivenessModule is ISemver { /// This can be updated by replacing with a new module. address internal immutable FALLBACK_OWNER; - /// @notice The storage slot used in the safe to store the guard address - /// keccak256("guard_manager.guard.address") - uint256 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; /// @notice Semantic version. /// @custom:semver 1.2.2 @@ -260,7 +260,7 @@ contract LivenessModule is ISemver { // Check that the guard has not been changed require( - address(LIVENESS_GUARD) == address(uint160(uint256(bytes32(SAFE.getStorageAt(GUARD_STORAGE_SLOT, 1))))), + address(LIVENESS_GUARD) == address(uint160(uint256(bytes32(SAFE.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1))))), "LivenessModule: guard has been changed" ); } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a6ca4ed7e6384..455d472ea31e3 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -9,6 +9,7 @@ import { Guard as IGuard, GuardManager } from "safe-contracts/base/GuardManager. // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import { SemverComp } from "src/libraries/SemverComp.sol"; +import { Constants } from "src/libraries/Constants.sol"; /// @title TimelockGuard /// @notice This guard provides timelock functionality for Safe transactions @@ -204,9 +205,7 @@ abstract contract TimelockGuard is IGuard { /// @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)); + address guard = abi.decode(_safe.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1), (address)); return guard == address(this); } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 8c2215022d017..c45781b8b5878 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.15; import { Test } from "forge-std/Test.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; +import { Constants } from "src/libraries/Constants.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; @@ -57,9 +58,7 @@ abstract contract LivenessModule2_TestUtils is Test, SafeTestTools { /// @notice Helper to get the guard address from a Safe function _getGuard(SafeInstance memory _safe) internal view returns (address) { - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(_safe.safe.getStorageAt(uint256(guardSlot), 1), (address)); + address guard = abi.decode(_safe.safe.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1), (address)); return guard; } } diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index ff175d3f6b2e3..056bd532c20fe 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -8,6 +8,7 @@ import { LivenessModuleConfig } from "scripts/deploy/DeployOwnership.s.sol"; import { Test } from "forge-std/Test.sol"; +import { Constants } from "src/libraries/Constants.sol"; import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; @@ -16,8 +17,6 @@ import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; contract DeployOwnershipTest is Test, DeployOwnership { address internal constant SENTINEL_MODULES = address(0x1); - // keccak256("guard_manager.guard.address") - bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; function setUp() public override { super.setUp(); From 0b38113a84f0129a49e02f128ede2ee7fd2b797f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 10:03:46 -0400 Subject: [PATCH 65/90] 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 a7eb869be7189..f10164a44038d 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -205,10 +205,10 @@ }, "src/safe/LivenessModule.sol:LivenessModule": { "initCodeHash": "0xa4a06e8778dbb6883ece8f56538ba15bc01b3031bba9a12ad9d187e7c8aaa942", - "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" + "sourceCodeHash": "0x2483dde167b39bf11e9eb0a0d0180ccfc499604c3b1087c5c14fc0dc408ae1d2" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x1aa0383f638d6acf8e34b85e29cceedee428630f457605180ab44ad7a70e4c4b", + "initCodeHash": "0x68567829042bbd450ffd477848ddcda288e36714846fdcc02c315f2d0d332da6", "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { From e525173791c1766bf4d86c8737ff31f9e87ce181 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 10:55:07 -0400 Subject: [PATCH 66/90] Remove LivenessModule from semver-lock --- packages/contracts-bedrock/snapshots/semver-lock.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index f10164a44038d..f70af8dd91c8f 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -203,10 +203,6 @@ "initCodeHash": "0x406db1c5a127f76970791b8a7f6ff62b81481ab25cf32615bfed551cdd5cd844", "sourceCodeHash": "0xca3712277637e9d1b63ed16e35ef968032c12be9187c36146c171ac3e9f0cd73" }, - "src/safe/LivenessModule.sol:LivenessModule": { - "initCodeHash": "0xa4a06e8778dbb6883ece8f56538ba15bc01b3031bba9a12ad9d187e7c8aaa942", - "sourceCodeHash": "0x2483dde167b39bf11e9eb0a0d0180ccfc499604c3b1087c5c14fc0dc408ae1d2" - }, "src/safe/SaferSafes.sol:SaferSafes": { "initCodeHash": "0x68567829042bbd450ffd477848ddcda288e36714846fdcc02c315f2d0d332da6", "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" @@ -235,4 +231,4 @@ "initCodeHash": "0x2bfce526f82622288333d53ca3f43a0a94306ba1bab99241daa845f8f4b18bd4", "sourceCodeHash": "0xf49d7b0187912a6bb67926a3222ae51121e9239495213c975b3b4b217ee57a1b" } -} \ No newline at end of file +} From 07ecba68bf804df00c4d6fe0b83cf9ebc2c0261f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 11:45:56 -0400 Subject: [PATCH 67/90] fix: fmt, semver-lock, unused imports --- packages/contracts-bedrock/snapshots/semver-lock.json | 6 +++++- packages/contracts-bedrock/src/safe/LivenessModule.sol | 4 ++-- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 1 - .../contracts-bedrock/test/scripts/DeployOwnership.t.sol | 1 - 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index f70af8dd91c8f..0a2fbb7d6192e 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -203,6 +203,10 @@ "initCodeHash": "0x406db1c5a127f76970791b8a7f6ff62b81481ab25cf32615bfed551cdd5cd844", "sourceCodeHash": "0xca3712277637e9d1b63ed16e35ef968032c12be9187c36146c171ac3e9f0cd73" }, + "src/safe/LivenessModule.sol:LivenessModule": { + "initCodeHash": "0xa4a06e8778dbb6883ece8f56538ba15bc01b3031bba9a12ad9d187e7c8aaa942", + "sourceCodeHash": "0x5bcd9f0f06a4d23183401702426c2907b1882813fd56f420c1f7f77f3bf771b2" + }, "src/safe/SaferSafes.sol:SaferSafes": { "initCodeHash": "0x68567829042bbd450ffd477848ddcda288e36714846fdcc02c315f2d0d332da6", "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" @@ -231,4 +235,4 @@ "initCodeHash": "0x2bfce526f82622288333d53ca3f43a0a94306ba1bab99241daa845f8f4b18bd4", "sourceCodeHash": "0xf49d7b0187912a6bb67926a3222ae51121e9239495213c975b3b4b217ee57a1b" } -} +} \ No newline at end of file diff --git a/packages/contracts-bedrock/src/safe/LivenessModule.sol b/packages/contracts-bedrock/src/safe/LivenessModule.sol index 1af049760ef69..9d12149abf735 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule.sol @@ -56,7 +56,6 @@ contract LivenessModule is ISemver { /// This can be updated by replacing with a new module. address internal immutable FALLBACK_OWNER; - /// @notice Semantic version. /// @custom:semver 1.2.2 string public constant version = "1.2.2"; @@ -260,7 +259,8 @@ contract LivenessModule is ISemver { // Check that the guard has not been changed require( - address(LIVENESS_GUARD) == address(uint160(uint256(bytes32(SAFE.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1))))), + address(LIVENESS_GUARD) + == address(uint160(uint256(bytes32(SAFE.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1))))), "LivenessModule: guard has been changed" ); } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 455d472ea31e3..40fecbcbe1b19 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 { Guard as IGuard, GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 703867e16e3a7..860345a8d6a49 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.15; import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; -import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { TimelockGuard } from "src/safe/TimelockGuard.sol"; diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index 056bd532c20fe..42ef9288d8e17 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -8,7 +8,6 @@ import { LivenessModuleConfig } from "scripts/deploy/DeployOwnership.s.sol"; import { Test } from "forge-std/Test.sol"; -import { Constants } from "src/libraries/Constants.sol"; import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; From 3f2a72c1684264091e757b0ffc6984d4fcfe233a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 12:34:09 -0400 Subject: [PATCH 68/90] Remove unused variable --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 860345a8d6a49..213df0f9242e3 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -191,7 +191,6 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Verify guard is deactivated assertEq(_getGuard(safeInstance), address(0)); - TimelockGuard timelockGuard = TimelockGuard(address(livenessModule2)); } function test_changeOwnershipToFallback_succeeds() external { From 73696686d73d825361d21506d78c612d265d046f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 12:41:26 -0400 Subject: [PATCH 69/90] fix semver lock by resetting old LivenessModule --- packages/contracts-bedrock/snapshots/semver-lock.json | 2 +- packages/contracts-bedrock/src/safe/LivenessModule.sol | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 0a2fbb7d6192e..14db9a762d612 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -205,7 +205,7 @@ }, "src/safe/LivenessModule.sol:LivenessModule": { "initCodeHash": "0xa4a06e8778dbb6883ece8f56538ba15bc01b3031bba9a12ad9d187e7c8aaa942", - "sourceCodeHash": "0x5bcd9f0f06a4d23183401702426c2907b1882813fd56f420c1f7f77f3bf771b2" + "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { "initCodeHash": "0x68567829042bbd450ffd477848ddcda288e36714846fdcc02c315f2d0d332da6", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule.sol b/packages/contracts-bedrock/src/safe/LivenessModule.sol index 9d12149abf735..7645f11903f47 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule.sol @@ -12,9 +12,6 @@ import { LivenessGuard } from "src/safe/LivenessGuard.sol"; // Interfaces import { ISemver } from "interfaces/universal/ISemver.sol"; -// Libraries -import { Constants } from "src/libraries/Constants.sol"; - /// @title LivenessModule /// @notice This module is intended to be used in conjunction with the LivenessGuard. In the event /// that an owner of the safe is not recorded by the guard during the liveness interval, @@ -56,6 +53,10 @@ contract LivenessModule is ISemver { /// This can be updated by replacing with a new module. address internal immutable FALLBACK_OWNER; + /// @notice The storage slot used in the safe to store the guard address + /// keccak256("guard_manager.guard.address") + uint256 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + /// @notice Semantic version. /// @custom:semver 1.2.2 string public constant version = "1.2.2"; @@ -259,8 +260,7 @@ contract LivenessModule is ISemver { // Check that the guard has not been changed require( - address(LIVENESS_GUARD) - == address(uint160(uint256(bytes32(SAFE.getStorageAt(uint256(Constants.GUARD_STORAGE_SLOT), 1))))), + address(LIVENESS_GUARD) == address(uint160(uint256(bytes32(SAFE.getStorageAt(GUARD_STORAGE_SLOT, 1))))), "LivenessModule: guard has been changed" ); } From 0434bfa2508c27774f0ea2cc02fad09570f7e316 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 12:49:55 -0400 Subject: [PATCH 70/90] fix unused import --- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 213df0f9242e3..5caa088827b40 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -6,7 +6,6 @@ import "test/safe-tools/SafeTestTools.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; -import { TimelockGuard } from "src/safe/TimelockGuard.sol"; import { GuardManager } from "safe-contracts/base/GuardManager.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; From 60e0f98640cbce30ab3040e990dd8d439a294230 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 10:52:34 -0400 Subject: [PATCH 71/90] Require that msgSender be an owner of the Safe --- .../snapshots/abi/SaferSafes.json | 7 +++++- .../src/safe/TimelockGuard.sol | 11 +++++++- .../test/safe/TimelockGuard.t.sol | 25 +++++++++++-------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index 827478086875b..0360375a66ce0 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -163,7 +163,7 @@ }, { "internalType": "address", - "name": "", + "name": "_msgSender", "type": "address" } ], @@ -902,6 +902,11 @@ "name": "TimelockGuard_InvalidVersion", "type": "error" }, + { + "inputs": [], + "name": "TimelockGuard_NotOwner", + "type": "error" + }, { "inputs": [], "name": "TimelockGuard_TransactionAlreadyCancelled", diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 40fecbcbe1b19..c744a9d6883f1 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -158,6 +158,9 @@ abstract contract TimelockGuard is IGuard { /// @param uncancelledCount The number of transactions that are not cancelled. event TransactionsNotCancelled(Safe indexed safe, uint256 uncancelledCount); + /// @notice Error for when the caller is not an owner of the Safe + error TimelockGuard_NotOwner(); + /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. /// @param timelockDelay The timelock delay in seconds. @@ -306,7 +309,7 @@ abstract contract TimelockGuard is IGuard { address _gasToken, address payable _refundReceiver, bytes memory, /* signatures */ - address /* msgSender */ + address _msgSender ) external view @@ -314,6 +317,12 @@ abstract contract TimelockGuard is IGuard { { Safe callingSafe = Safe(payable(msg.sender)); + // Limit execution of transactions to owners of the Safe only. + // This ensures that an attacker cannot simply collect valid signatures, but must also control a private key. + if (!callingSafe.isOwner(_msgSender)) { + revert TimelockGuard_NotOwner(); + } + 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. diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 1878182254f29..8279872e176bb 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -83,7 +83,8 @@ library TransactionBuilder { } /// @notice Executes the transaction via the underlying Safe contract. - function executeTransaction(Transaction memory _tx) internal { + function executeTransaction(Transaction memory _tx, address _owner) internal { + Vm(VM_ADDR).prank(_owner); _tx.safeInstance.safe.execTransaction( _tx.params.to, _tx.params.value, @@ -228,9 +229,11 @@ abstract contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Helper to configure the TimelockGuard for a Safe function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { + vm.startPrank(_safe.owners[0]); SafeTestLib.execTransaction( _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)) ); + vm.stopPrank(); } /// @notice Helper to enable guard on a Safe @@ -539,7 +542,7 @@ contract TimelockGuard_PendingTransactions_Test is TimelockGuard_TestInit { vm.warp(block.timestamp + TIMELOCK_DELAY); // execute the transaction - dummyTx.executeTransaction(); + dummyTx.executeTransaction(safeInstance.owners[0]); // get the pending transactions TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactions(safe); @@ -665,7 +668,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { dummyTx.params.gasToken, dummyTx.params.refundReceiver, "", - address(0) + safeInstance.owners[0] ); } @@ -698,7 +701,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { dummyTx.params.gasToken, dummyTx.params.refundReceiver, "", - address(0) + safeInstance.owners[0] ); } @@ -721,7 +724,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { dummyTx.params.gasToken, dummyTx.params.refundReceiver, "", - address(0) + safeInstance.owners[0] ); } } @@ -808,7 +811,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { vm.expectEmit(true, true, true, true); emit TransactionExecuted(safeInstance.safe, dummyTx.hash); - dummyTx.executeTransaction(); + dummyTx.executeTransaction(safeInstance.owners[0]); // Confirm that the transaction is executed TimelockGuard.ScheduledTransaction memory scheduledTransaction = @@ -825,10 +828,10 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { dummyTx.scheduleTransaction(timelockGuard); vm.warp(block.timestamp + TIMELOCK_DELAY); - dummyTx.executeTransaction(); + dummyTx.executeTransaction(safeInstance.owners[0]); vm.expectRevert("GS026"); - dummyTx.executeTransaction(); + dummyTx.executeTransaction(safeInstance.owners[0]); } function test_integration_scheduleThenExecuteThenCancel_reverts() external { @@ -836,7 +839,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { dummyTx.scheduleTransaction(timelockGuard); vm.warp(block.timestamp + TIMELOCK_DELAY); - dummyTx.executeTransaction(); + dummyTx.executeTransaction(safeInstance.owners[0]); TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyExecuted.selector); @@ -864,7 +867,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { resetGuardTx.scheduleTransaction(timelockGuard); vm.warp(block.timestamp + TIMELOCK_DELAY); - resetGuardTx.executeTransaction(); + resetGuardTx.executeTransaction(safeInstance.owners[0]); TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); disableGuardTx.params.to = address(safeInstance.safe); @@ -872,7 +875,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { disableGuardTx.updateTransaction(); vm.warp(block.timestamp + TIMELOCK_DELAY); - disableGuardTx.executeTransaction(); + disableGuardTx.executeTransaction(safeInstance.owners[0]); } /// @notice Test that the max cancellation threshold is not exceeded From 38e6529bd5c8d59b51083b28288a81de96987226 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 13:08:45 -0400 Subject: [PATCH 72/90] fix compiler error --- 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 8279872e176bb..ffe6fd108d87f 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -256,7 +256,7 @@ abstract contract TimelockGuard_TestInit is Test, SafeTestTools { vm.warp(block.timestamp + TIMELOCK_DELAY + 1); // Execute the disable guard transaction - disableGuardTx.executeTransaction(); + disableGuardTx.executeTransaction(_safe.owners[0]); } } From f87683a795015c3c2dc6eef1e7ed5ac5acdd9832 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 14:11:03 -0400 Subject: [PATCH 73/90] Fix placement of _msgSender check --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index c744a9d6883f1..c38c1222f1fae 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -317,12 +317,6 @@ abstract contract TimelockGuard is IGuard { { Safe callingSafe = Safe(payable(msg.sender)); - // Limit execution of transactions to owners of the Safe only. - // This ensures that an attacker cannot simply collect valid signatures, but must also control a private key. - if (!callingSafe.isOwner(_msgSender)) { - revert TimelockGuard_NotOwner(); - } - 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. @@ -332,6 +326,12 @@ abstract contract TimelockGuard is IGuard { return; } + // Limit execution of transactions to owners of the Safe only. + // This ensures that an attacker cannot simply collect valid signatures, but must also control a private key. + if (!callingSafe.isOwner(_msgSender)) { + revert TimelockGuard_NotOwner(); + } + // 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. From b9ea64ab3b21b5f774a293222b824972c9ca75c9 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 16 Oct 2025 16:26:30 -0400 Subject: [PATCH 74/90] semver-lock --- packages/contracts-bedrock/snapshots/semver-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 14db9a762d612..a4f9773632074 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,7 +208,7 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x68567829042bbd450ffd477848ddcda288e36714846fdcc02c315f2d0d332da6", + "initCodeHash": "0x4c5bc5043462addc841d81f441aa82e7618b417c43894a93b2404734dbad3292", "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { From 2bc29d175826acee7e674f30671f5d37f1328331 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 19:21:47 -0400 Subject: [PATCH 75/90] Add TimelockGuard_NotOwner test --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index ffe6fd108d87f..28101d65944ca 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -727,6 +727,14 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { safeInstance.owners[0] ); } + + function test_checkTransaction_notOwner_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + vm.expectRevert(TimelockGuard.TimelockGuard_NotOwner.selector); + dummyTx.executeTransaction(makeAddr("non-owner")); + } } /// @title TimelockGuard_MaxCancellationThreshold_Test From 441cdc6126bbb91f046c52e52bc1b0fd60c1644a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 17 Oct 2025 20:28:57 -0400 Subject: [PATCH 76/90] Bump semver --- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/src/safe/SaferSafes.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index a4f9773632074..92f1fe46624f4 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0x4c5bc5043462addc841d81f441aa82e7618b417c43894a93b2404734dbad3292", - "sourceCodeHash": "0x06fc6d5df3c5769b645ff319d8b94415e501711c19cb0b1bce2631771d22294f" + "initCodeHash": "0xde571369d6823ac315c03a862df84fa9fc0e9a7e58fd6227c5513d06ffdc6ba1", + "sourceCodeHash": "0x9d76fa66c0bd1261ab3f6c32892a0942ce4c10a5dfa169c012985dd43ea1cdde" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index 53b64c8a73a65..a70615ba2bc8c 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -22,8 +22,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// functionality is not desired, then there is no need to enable or configure it. contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { /// @notice Semantic version. - /// @custom:semver 1.1.0 - string public constant version = "1.1.0"; + /// @custom:semver 1.2.0 + string public constant version = "1.2.0"; /// @notice Error for when the liveness response period is insufficient. error SaferSafes_InsufficientLivenessResponsePeriod(); From ef5010f5775731c266fd59ecef6a5ae4fc960a98 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 11:44:26 -0400 Subject: [PATCH 77/90] Add test comment, make into fuzz test --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 28101d65944ca..2dcefe87d41cf 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -728,12 +728,14 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { ); } - function test_checkTransaction_notOwner_reverts() external { + /// @notice Test that checkTransaction reverts when the caller is not an owner + function testFuzz_checkTransaction_notOwner_reverts(address nonOwner) external { + vm.assume(!safeInstance.safe.isOwner(nonOwner)); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); vm.expectRevert(TimelockGuard.TimelockGuard_NotOwner.selector); - dummyTx.executeTransaction(makeAddr("non-owner")); + dummyTx.executeTransaction(nonOwner); } } From bc167dcf289bbcfb9334849d48ef41e3a5a3c27a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 12:54:55 -0400 Subject: [PATCH 78/90] Improvements to SaferSafes styling (#17903) * Add public getter livenessSafeConfiguration to return a struct rather than tuple * Use Safe as input type to mappings and functions on LivenessModule2 * Add dividers based on function type * fmt * snapshots * Remove conditional return of 0 in the cancellationThreshold if the guard is not enabled * rename timelockConfiguration func to timelockDelay * semver-lock * Add missing natspec on tests and convert to fuzzing where possible --- .../scripts/deploy/DeployOwnership.s.sol | 8 +- .../snapshots/abi/SaferSafes.json | 35 +-- .../snapshots/semver-lock.json | 4 +- .../snapshots/storageLayout/SaferSafes.json | 6 +- .../src/safe/LivenessModule2.sol | 126 ++++++---- .../contracts-bedrock/src/safe/SaferSafes.sol | 2 +- .../src/safe/TimelockGuard.sol | 7 +- .../test/safe/LivenessModule2.t.sol | 233 ++++++++++++++---- .../test/safe/SaferSafes.t.sol | 54 ++-- .../test/safe/TimelockGuard.t.sol | 28 +-- .../test/scripts/DeployOwnership.t.sol | 10 +- 11 files changed, 337 insertions(+), 176 deletions(-) diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index ebbc244e9c322..d340380e96fee 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -324,14 +324,14 @@ contract DeployOwnership is Deploy { removeDeployerFromSafe({ _name: "SecurityCouncilSafe", _newThreshold: exampleCouncilConfig.safeConfig.threshold }); // Verify the module was configured correctly - (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).livenessSafeConfiguration(address(safe)); + LivenessModule2.ModuleConfig memory verifyConfig = + LivenessModule2(livenessModule).livenessSafeConfiguration(safe); require( - configuredPeriod == exampleCouncilConfig.livenessModuleConfig.livenessInterval, + verifyConfig.livenessResponsePeriod == exampleCouncilConfig.livenessModuleConfig.livenessInterval, "DeployOwnership: configured liveness interval must match expected value" ); require( - configuredFallback == exampleCouncilConfig.livenessModuleConfig.fallbackOwner, + verifyConfig.fallbackOwner == exampleCouncilConfig.livenessModuleConfig.fallbackOwner, "DeployOwnership: configured fallback owner must match expected value" ); diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index 0360375a66ce0..fa401d9f82a2f 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -49,7 +49,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" } @@ -62,7 +62,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "", "type": "address" } @@ -81,7 +81,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" } @@ -227,7 +227,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" } @@ -246,22 +246,29 @@ { "inputs": [ { - "internalType": "address", - "name": "", + "internalType": "contract GnosisSafe", + "name": "_safe", "type": "address" } ], "name": "livenessSafeConfiguration", "outputs": [ { - "internalType": "uint256", - "name": "livenessResponsePeriod", - "type": "uint256" - }, - { - "internalType": "address", - "name": "fallbackOwner", - "type": "address" + "components": [ + { + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "internalType": "struct LivenessModule2.ModuleConfig", + "name": "", + "type": "tuple" } ], "stateMutability": "view", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 92f1fe46624f4..ffcf038b24497 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x950725f8b9ad9bb3b6b5e836f67e18db824a7864bac547ee0eeba88ada3de0e9" }, "src/safe/SaferSafes.sol:SaferSafes": { - "initCodeHash": "0xde571369d6823ac315c03a862df84fa9fc0e9a7e58fd6227c5513d06ffdc6ba1", - "sourceCodeHash": "0x9d76fa66c0bd1261ab3f6c32892a0942ce4c10a5dfa169c012985dd43ea1cdde" + "initCodeHash": "0xaa17bb150c9bcf19675a33e9762b050148aceae9f6a9a6ba020fc6947ebaab39", + "sourceCodeHash": "0xc4201612048ff051ed795521efa3eece1a6556f2c514a268b180d84a2ad8b2d1" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json b/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json index 17b7d237e9f5f..c8fa41d7a3c48 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/SaferSafes.json @@ -1,17 +1,17 @@ [ { "bytes": "32", - "label": "livenessSafeConfiguration", + "label": "_livenessSafeConfiguration", "offset": 0, "slot": "0", - "type": "mapping(address => struct LivenessModule2.ModuleConfig)" + "type": "mapping(contract GnosisSafe => struct LivenessModule2.ModuleConfig)" }, { "bytes": "32", "label": "challengeStartTime", "offset": 0, "slot": "1", - "type": "mapping(address => uint256)" + "type": "mapping(contract GnosisSafe => uint256)" }, { "bytes": "32", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 1feb559568913..fe024bc757d0b 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -27,10 +27,10 @@ abstract contract LivenessModule2 { } /// @notice Mapping from Safe address to its configuration. - mapping(address => ModuleConfig) public livenessSafeConfiguration; + mapping(Safe => ModuleConfig) internal _livenessSafeConfiguration; /// @notice Mapping from Safe address to active challenge start time (0 if none). - mapping(address => uint256) public challengeStartTime; + mapping(Safe => uint256) public challengeStartTime; /// @notice Reserved address used as previous owner to the first owner in a Safe. address internal constant SENTINEL_OWNER = address(0x1); @@ -95,23 +95,40 @@ abstract contract LivenessModule2 { /// @param fallbackOwner The address that claimed ownership if the Safe is unresponsive. event ChallengeSucceeded(address indexed safe, address fallbackOwner); + //////////////////////////////////////////////////////////////// + // External View Functions // + //////////////////////////////////////////////////////////////// + /// @notice Returns challenge_start_time + liveness_response_period if challenge exists, or /// 0 if not. /// @param _safe The Safe address to query. /// @return The challenge end timestamp, or 0 if no challenge. - function getChallengePeriodEnd(address _safe) public view returns (uint256) { + function getChallengePeriodEnd(Safe _safe) public view returns (uint256) { uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { return 0; } - ModuleConfig storage config = livenessSafeConfiguration[_safe]; + ModuleConfig storage config = _livenessSafeConfiguration[_safe]; return startTime + config.livenessResponsePeriod; } + /// @notice Returns the configuration for a given Safe. + /// @param _safe The Safe address to query. + /// @return The ModuleConfig for the Safe. + function livenessSafeConfiguration(Safe _safe) public view returns (ModuleConfig memory) { + return _livenessSafeConfiguration[_safe]; + } + + //////////////////////////////////////////////////////////////// + // External State-Changing Functions // + //////////////////////////////////////////////////////////////// + /// @notice Configures the module for a Safe that has already enabled it. /// @param _config The configuration parameters for the module containing the response /// period and fallback owner. function configureLivenessModule(ModuleConfig memory _config) external { + Safe callingSafe = Safe(payable(msg.sender)); + // Validate configuration parameters to ensure module can function properly. // livenessResponsePeriod must be > 0 to allow time for Safe owners to respond. if (_config.livenessResponsePeriod == 0) { @@ -123,10 +140,10 @@ abstract contract LivenessModule2 { } // Check that this module is enabled on the calling Safe. - _assertModuleEnabled(msg.sender); + _assertModuleEnabled(callingSafe); // Store the configuration for this safe - livenessSafeConfiguration[msg.sender] = _config; + _livenessSafeConfiguration[callingSafe] = _config; // Clear any existing challenge when configuring/re-configuring. // This is necessary because changing the configuration (especially @@ -137,19 +154,14 @@ abstract contract LivenessModule2 { // Additionally, a Safe that is able to successfully trigger the configuration function // is necessarily live, so cancelling the challenge also makes sense from a // theoretical standpoint. - _cancelChallenge(msg.sender); + _cancelChallenge(callingSafe); emit ModuleConfigured(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); // Verify that any other extensions which are enabled on the Safe are configured correctly. - _checkCombinedConfig(Safe(payable(msg.sender))); + _checkCombinedConfig(callingSafe); } - /// @notice Internal helper function which can be overriden in a child contract to check if the guard's - /// configuration is valid in the context of other extensions that are enabled on the Safe. - /// @param _safe The Safe instance to check the configuration against - function _checkCombinedConfig(Safe _safe) internal view virtual; - /// @notice Clears the module configuration for a Safe. /// @dev Note: Clearing the configuration also cancels any ongoing challenges. /// This function is intended for use when a Safe wants to permanently remove @@ -160,23 +172,25 @@ abstract contract LivenessModule2 { /// Never calling clearLivenessModule() after disabling keeps configuration data persistent /// for potential future re-enabling. function clearLivenessModule() external { + Safe callingSafe = Safe(payable(msg.sender)); + // Check if the calling safe has configuration set - _assertModuleConfigured(msg.sender); + _assertModuleConfigured(callingSafe); // Check that this module is NOT enabled on the calling Safe // This prevents clearing configuration while module is still enabled - _assertModuleNotEnabled(msg.sender); + _assertModuleNotEnabled(callingSafe); // Erase the configuration data for this safe - delete livenessSafeConfiguration[msg.sender]; + delete _livenessSafeConfiguration[callingSafe]; // Also clear any active challenge - _cancelChallenge(msg.sender); - emit ModuleCleared(msg.sender); + _cancelChallenge(callingSafe); + emit ModuleCleared(address(callingSafe)); } /// @notice Challenges an enabled safe. /// @param _safe The Safe address to challenge. - function challenge(address _safe) external { + function challenge(Safe _safe) external { // Check if the calling safe has configuration set _assertModuleConfigured(_safe); @@ -184,7 +198,7 @@ abstract contract LivenessModule2 { _assertModuleEnabled(_safe); // Check that the caller is the fallback owner - if (msg.sender != livenessSafeConfiguration[_safe].fallbackOwner) { + if (msg.sender != _livenessSafeConfiguration[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -195,26 +209,28 @@ abstract contract LivenessModule2 { // Set the challenge start time and emit the event challengeStartTime[_safe] = block.timestamp; - emit ChallengeStarted(_safe, block.timestamp); + emit ChallengeStarted(address(_safe), block.timestamp); } /// @notice Responds to a challenge for an enabled safe, canceling it. function respond() external { + Safe callingSafe = Safe(payable(msg.sender)); + // Check if the calling safe has configuration set. - _assertModuleConfigured(msg.sender); + _assertModuleConfigured(callingSafe); // Check that this module is enabled on the calling Safe. - _assertModuleEnabled(msg.sender); + _assertModuleEnabled(callingSafe); // Check that a challenge exists - uint256 startTime = challengeStartTime[msg.sender]; + uint256 startTime = challengeStartTime[callingSafe]; if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); } // Cancel the challenge without checking if response period has expired // This allows the Safe to respond at any time, providing more flexibility - _cancelChallenge(msg.sender); + _cancelChallenge(callingSafe); } /// @notice With successful challenge, removes all current owners from enabled safe, @@ -224,7 +240,7 @@ abstract contract LivenessModule2 { /// fallback owner effectively becomes its own fallback owner, maintaining /// the ability to challenge itself if needed. /// @param _safe The Safe address to transfer ownership of. - function changeOwnershipToFallback(address _safe) external { + function changeOwnershipToFallback(Safe _safe) external { // Ensure Safe is configured with this module to prevent unauthorized execution. _assertModuleConfigured(_safe); @@ -232,7 +248,7 @@ abstract contract LivenessModule2 { _assertModuleEnabled(_safe); // Only fallback owner can execute ownership transfer (per specs update) - if (msg.sender != livenessSafeConfiguration[_safe].fallbackOwner) { + if (msg.sender != _livenessSafeConfiguration[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -248,61 +264,67 @@ abstract contract LivenessModule2 { revert LivenessModule2_ResponsePeriodActive(); } - Safe targetSafe = Safe(payable(_safe)); - // Get current owners - address[] memory owners = targetSafe.getOwners(); + address[] memory owners = _safe.getOwners(); // Remove all owners after the first one // Note: This loop is safe as real-world Safes have limited owners (typically < 10) // Gas limits would only be a concern with hundreds/thousands of owners while (owners.length > 1) { - targetSafe.execTransactionFromModule({ - to: _safe, + _safe.execTransactionFromModule({ + to: address(_safe), value: 0, operation: Enum.Operation.Call, data: abi.encodeCall(OwnerManager.removeOwner, (SENTINEL_OWNER, owners[0], 1)) }); - owners = targetSafe.getOwners(); + owners = _safe.getOwners(); } // Now swap the remaining single owner with the fallback owner - targetSafe.execTransactionFromModule({ - to: _safe, + _safe.execTransactionFromModule({ + to: address(_safe), value: 0, operation: Enum.Operation.Call, data: abi.encodeCall( - OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], livenessSafeConfiguration[_safe].fallbackOwner) + OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], _livenessSafeConfiguration[_safe].fallbackOwner) ) }); // Sanity check: verify the fallback owner is now the only owner - address[] memory finalOwners = targetSafe.getOwners(); - if (finalOwners.length != 1 || finalOwners[0] != livenessSafeConfiguration[_safe].fallbackOwner) { + address[] memory finalOwners = _safe.getOwners(); + if (finalOwners.length != 1 || finalOwners[0] != _livenessSafeConfiguration[_safe].fallbackOwner) { revert LivenessModule2_OwnershipTransferFailed(); } // Reset the challenge state to allow a new challenge delete challengeStartTime[_safe]; - emit ChallengeSucceeded(_safe, livenessSafeConfiguration[_safe].fallbackOwner); // Disable the guard // Note that this will remove whichever guard is currently set on the Safe, // even if it is not the SaferSafes guard. This is intentional, as it is possible that the guard // itself was the cause of the liveness failure which resulted in the transfer of ownership to // the fallback owner. - targetSafe.execTransactionFromModule({ - to: _safe, + _safe.execTransactionFromModule({ + to: address(_safe), value: 0, operation: Enum.Operation.Call, data: abi.encodeCall(GuardManager.setGuard, (address(0))) }); + emit ChallengeSucceeded(address(_safe), _livenessSafeConfiguration[_safe].fallbackOwner); } + //////////////////////////////////////////////////////////////// + // Internal View Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Internal helper function which can be overriden in a child contract to check if the guard's + /// configuration is valid in the context of other extensions that are enabled on the Safe. + function _checkCombinedConfig(Safe _safe) internal view virtual; + /// @notice Asserts that the module is configured for the given Safe. /// @param _safe The Safe address to check. - function _assertModuleConfigured(address _safe) internal view { - ModuleConfig storage config = livenessSafeConfiguration[_safe]; + function _assertModuleConfigured(Safe _safe) internal view { + ModuleConfig storage config = _livenessSafeConfiguration[_safe]; if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotConfigured(); } @@ -310,29 +332,31 @@ abstract contract LivenessModule2 { /// @notice Asserts that the module is enabled for the given Safe. /// @param _safe The Safe address to check. - function _assertModuleEnabled(address _safe) internal view { - Safe safe = Safe(payable(_safe)); - if (!safe.isModuleEnabled(address(this))) { + function _assertModuleEnabled(Safe _safe) internal view { + if (!_safe.isModuleEnabled(address(this))) { revert LivenessModule2_ModuleNotEnabled(); } } /// @notice Asserts that the module is not enabled for the given Safe. /// @param _safe The Safe address to check. - function _assertModuleNotEnabled(address _safe) internal view { - Safe safe = Safe(payable(_safe)); - if (safe.isModuleEnabled(address(this))) { + function _assertModuleNotEnabled(Safe _safe) internal view { + if (_safe.isModuleEnabled(address(this))) { revert LivenessModule2_ModuleStillEnabled(); } } + //////////////////////////////////////////////////////////////// + // Internal State-Changing Functions // + //////////////////////////////////////////////////////////////// + /// @notice Internal function to cancel a challenge and emit the appropriate event. /// @param _safe The Safe address for which to cancel the challenge. - function _cancelChallenge(address _safe) internal { + function _cancelChallenge(Safe _safe) internal { // Early return if no challenge exists if (challengeStartTime[_safe] == 0) return; delete challengeStartTime[_safe]; - emit ChallengeCancelled(_safe); + emit ChallengeCancelled(address(_safe)); } } diff --git a/packages/contracts-bedrock/src/safe/SaferSafes.sol b/packages/contracts-bedrock/src/safe/SaferSafes.sol index a70615ba2bc8c..52ea97df94e07 100644 --- a/packages/contracts-bedrock/src/safe/SaferSafes.sol +++ b/packages/contracts-bedrock/src/safe/SaferSafes.sol @@ -39,7 +39,7 @@ contract SaferSafes is LivenessModule2, TimelockGuard, ISemver { } uint256 timelockDelay = _safeState[_safe].timelockDelay; - uint256 livenessResponsePeriod = livenessSafeConfiguration[address(_safe)].livenessResponsePeriod; + uint256 livenessResponsePeriod = _livenessSafeConfiguration[_safe].livenessResponsePeriod; // If the timelock delay is 0, then the timelock guard is enabled but not configured. // No delay is applied to transactions, so we don't need to perform any further checks. diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index c38c1222f1fae..2276cbdcf9915 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -224,11 +224,6 @@ abstract contract TimelockGuard is IGuard { /// @param _safe The Safe address to query /// @return The current cancellation threshold function cancellationThreshold(Safe _safe) public view returns (uint256) { - // Return 0 if guard is not enabled - if (!_isGuardEnabled(_safe)) { - return 0; - } - return _safeState[_safe].cancellationThreshold; } @@ -254,7 +249,7 @@ abstract contract TimelockGuard is IGuard { /// @notice Returns the timelock delay for a given Safe /// @param _safe The Safe address to query /// @return The timelock delay in seconds - function timelockConfiguration(Safe _safe) public view returns (uint256) { + function timelockDelay(Safe _safe) public view returns (uint256) { return _safeState[_safe].timelockDelay; } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index c45781b8b5878..24680065089e2 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.15; import { Test } from "forge-std/Test.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import "test/safe-tools/SafeTestTools.sol"; import { Constants } from "src/libraries/Constants.sol"; @@ -116,10 +117,10 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(period, CHALLENGE_PERIOD); - assertEq(fbOwner, fallbackOwner); - assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + LivenessModule2.ModuleConfig memory config = livenessModule2.livenessSafeConfiguration(safeInstance.safe); + assertEq(config.livenessResponsePeriod, CHALLENGE_PERIOD); + assertEq(config.fallbackOwner, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(safeInstance.safe), 0); } function test_configureLivenessModule_multipleSafes_succeeds() external { @@ -146,17 +147,17 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni _enableModule(safe3, 3 days, fallback3); // Verify each safe has independent configuration - (uint256 period1, address fb1) = livenessModule2.livenessSafeConfiguration(address(safe1.safe)); - assertEq(period1, 1 days); - assertEq(fb1, fallback1); + LivenessModule2.ModuleConfig memory config1 = livenessModule2.livenessSafeConfiguration(safe1.safe); + assertEq(config1.livenessResponsePeriod, 1 days); + assertEq(config1.fallbackOwner, fallback1); - (uint256 period2, address fb2) = livenessModule2.livenessSafeConfiguration(address(safe2.safe)); - assertEq(period2, 2 days); - assertEq(fb2, fallback2); + LivenessModule2.ModuleConfig memory config2 = livenessModule2.livenessSafeConfiguration(safe2.safe); + assertEq(config2.livenessResponsePeriod, 2 days); + assertEq(config2.fallbackOwner, fallback2); - (uint256 period3, address fb3) = livenessModule2.livenessSafeConfiguration(address(safe3.safe)); - assertEq(period3, 3 days); - assertEq(fb3, fallback3); + LivenessModule2.ModuleConfig memory config3 = livenessModule2.livenessSafeConfiguration(safe3.safe); + assertEq(config3.livenessResponsePeriod, 3 days); + assertEq(config3.fallbackOwner, fallback3); } function test_configureLivenessModule_requiresSafeModuleInstallation_reverts() external { @@ -197,10 +198,10 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Verify challenge exists - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertGt(challengeEndTime, 0); // Reconfigure the module, which should cancel the challenge and emit ChallengeCancelled @@ -215,7 +216,7 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni ); // Verify challenge was cancelled - challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, 0); } @@ -243,9 +244,9 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni Enum.Operation.Call ); - (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(period, 0); - assertEq(fbOwner, address(0)); + LivenessModule2.ModuleConfig memory clearedConfig = livenessModule2.livenessSafeConfiguration(safeInstance.safe); + assertEq(clearedConfig.livenessResponsePeriod, 0); + assertEq(clearedConfig.fallbackOwner, address(0)); } function test_clear_notEnabled_reverts() external { @@ -277,9 +278,9 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { emit ChallengeStarted(address(safeInstance.safe), block.timestamp); vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, block.timestamp + CHALLENGE_PERIOD); } @@ -288,7 +289,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); vm.prank(notFallback); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); } function test_challenge_moduleNotEnabled_reverts() external { @@ -296,16 +297,16 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(fallbackOwner); - livenessModule2.challenge(newSafe); + livenessModule2.challenge(Safe(payable(newSafe))); } function test_challenge_alreadyExists_reverts() external { vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); } function test_challenge_moduleDisabledAtSafeLevel_reverts() external { @@ -331,13 +332,13 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { // Try to challenge - should revert because module is disabled at Safe level vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(fallbackOwner); - livenessModule2.challenge(address(disabledSafe.safe)); + livenessModule2.challenge(disabledSafe.safe); } function test_respond_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Cancel it vm.expectEmit(true, true, true, true); @@ -346,7 +347,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { _respondToChallenge(safeInstance); // Verify challenge is cancelled - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, 0); } @@ -376,7 +377,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function test_respond_afterResponsePeriod_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -388,7 +389,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { livenessModule2.respond(); // Verify challenge was cancelled - assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + assertEq(livenessModule2.challengeStartTime(safeInstance.safe), 0); } function test_respond_moduleNotConfigured_reverts() external { @@ -423,9 +424,10 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { ); // Verify the Safe still has configuration but module is not enabled - (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(configuredSafe.safe)); - assertTrue(period > 0); // Configuration exists - assertTrue(fbOwner != address(0)); // Configuration exists + LivenessModule2.ModuleConfig memory configuredConfig = + livenessModule2.livenessSafeConfiguration(configuredSafe.safe); + assertTrue(configuredConfig.livenessResponsePeriod > 0); // Configuration exists + assertTrue(configuredConfig.fallbackOwner != address(0)); // Configuration exists assertFalse(configuredSafe.safe.isModuleEnabled(address(livenessModule2))); // Module not enabled // Now respond() should revert because module is not enabled @@ -435,37 +437,178 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { } } +/// @title LivenessModule2_ChangeOwnershipToFallback_Test +/// @notice Tests the ownership transfer after successful challenge +contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestInit { + function setUp() public override { + super.setUp(); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + } + + /// @notice Tests that ownership successfully transfers to fallback owner after challenge period expires + function testFuzz_changeOwnershipToFallback_succeeds(uint256 timeAfterExpiry) external { + // Bound time to reasonable values (1 second to 365 days after expiry) + timeAfterExpiry = bound(timeAfterExpiry, 1, 365 days); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + timeAfterExpiry); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + assertEq(safeInstance.safe.getThreshold(), 1); + + // Verify challenge is reset + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); + assertEq(challengeEndTime, 0); + } + + /// @notice Tests that changeOwnershipToFallback reverts if module is not configured + function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + livenessModule2.changeOwnershipToFallback(Safe(payable(newSafe))); + } + + /// @notice Tests that changeOwnershipToFallback reverts if no challenge exists + function test_changeOwnershipToFallback_noChallenge_reverts() external { + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + } + + /// @notice Tests that changeOwnershipToFallback reverts if called before response period expires + function testFuzz_changeOwnershipToFallback_beforeResponsePeriod_reverts(uint256 timeElapsed) external { + // Bound time to be within response period (not yet expired) + timeElapsed = bound(timeElapsed, 0, CHALLENGE_PERIOD); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + // Warp to a time before response period expires + vm.warp(block.timestamp + timeElapsed); + + // Try to execute before response period expires + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + } + + /// @notice Tests that changeOwnershipToFallback reverts if module is disabled at Safe level + function test_changeOwnershipToFallback_moduleDisabledAtSafeLevel_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Disable the module at Safe level + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Try to execute ownership transfer - should revert because module is disabled at Safe level + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + } + + /// @notice Tests that only the fallback owner can execute changeOwnershipToFallback + function testFuzz_changeOwnershipToFallback_onlyFallbackOwner_succeeds(address _caller) external { + vm.assume(_caller != fallbackOwner); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Try from non-fallback owner - should fail + vm.prank(_caller); + vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + + // Execute from fallback owner - should succeed + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + } + + /// @notice Tests that a new challenge can be started after ownership transfer completes + function test_changeOwnershipToFallback_canRechallenge_succeeds() external { + // Start and execute first challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); + + // Start a new challenge (as fallback owner) + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); + assertGt(challengeEndTime, 0); + } +} + /// @title LivenessModule2_GetChallengePeriodEnd_Test /// @notice Tests the getChallengePeriodEnd function and related view functionality contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit { function test_safeConfigs_succeeds() external { // Before enabling - (uint256 period1, address fbOwner1) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(period1, 0); - assertEq(fbOwner1, address(0)); - assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + LivenessModule2.ModuleConfig memory configBefore = livenessModule2.livenessSafeConfiguration(safeInstance.safe); + assertEq(configBefore.livenessResponsePeriod, 0); + assertEq(configBefore.fallbackOwner, address(0)); + assertEq(livenessModule2.challengeStartTime(safeInstance.safe), 0); // After enabling _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period2, address fbOwner2) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(period2, CHALLENGE_PERIOD); - assertEq(fbOwner2, fallbackOwner); - assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + LivenessModule2.ModuleConfig memory configAfter = livenessModule2.livenessSafeConfiguration(safeInstance.safe); + assertEq(configAfter.livenessResponsePeriod, CHALLENGE_PERIOD); + assertEq(configAfter.fallbackOwner, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(safeInstance.safe), 0); } function test_getChallengePeriodEnd_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // No challenge - assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); + assertEq(livenessModule2.getChallengePeriodEnd(safeInstance.safe), 0); // With challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); - assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); + livenessModule2.challenge(safeInstance.safe); + assertEq(livenessModule2.getChallengePeriodEnd(safeInstance.safe), block.timestamp + CHALLENGE_PERIOD); // After cancellation _respondToChallenge(safeInstance); - assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); + assertEq(livenessModule2.getChallengePeriodEnd(safeInstance.safe), 0); } } diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 5caa088827b40..36b9532d40ad6 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -85,11 +85,10 @@ contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { saferSafes.configureTimelockGuard(timelockDelay); // Verify configurations were set - (uint256 storedLivenessResponsePeriod, address storedFallbackOwner) = - saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); - assertEq(storedFallbackOwner, fallbackOwner); - assertEq(saferSafes.timelockConfiguration(safeInstance.safe), timelockDelay); + LivenessModule2.ModuleConfig memory storedConfig = saferSafes.livenessSafeConfiguration(safeInstance.safe); + assertEq(storedConfig.livenessResponsePeriod, livenessResponsePeriod); + assertEq(storedConfig.fallbackOwner, fallbackOwner); + assertEq(saferSafes.timelockDelay(safeInstance.safe), timelockDelay); } function test_configure_timelockGuardFirst_succeeds() public { @@ -110,11 +109,10 @@ contract SaferSafes_Uncategorized_Test is SaferSafes_TestInit { saferSafes.configureLivenessModule(moduleConfig); // Verify configurations were set - (uint256 storedLivenessResponsePeriod, address storedFallbackOwner) = - saferSafes.livenessSafeConfiguration(address(safeInstance.safe)); - assertEq(storedLivenessResponsePeriod, livenessResponsePeriod); - assertEq(storedFallbackOwner, fallbackOwner); - assertEq(saferSafes.timelockConfiguration(safeInstance.safe), timelockDelay); + LivenessModule2.ModuleConfig memory storedConfig = saferSafes.livenessSafeConfiguration(safeInstance.safe); + assertEq(storedConfig.livenessResponsePeriod, livenessResponsePeriod); + assertEq(storedConfig.fallbackOwner, fallbackOwner); + assertEq(saferSafes.timelockDelay(safeInstance.safe), timelockDelay); } /// @notice Test that attempting to incorrectly configure the timelock guard after first configuring the liveness @@ -185,7 +183,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { assertEq(safeInstance.safe.getThreshold(), 1); // Verify challenge is reset - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, 0); // Verify guard is deactivated @@ -195,7 +193,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { function test_changeOwnershipToFallback_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -205,7 +203,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); _assertOwnershipChanged(); } @@ -235,7 +233,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -245,7 +243,7 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); // These checks include ensuring that the guard is deactivated _assertOwnershipChanged(); @@ -256,30 +254,30 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { vm.prank(fallbackOwner); vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); - livenessModule2.changeOwnershipToFallback(newSafe); + livenessModule2.changeOwnershipToFallback(Safe(payable(newSafe))); } function test_changeOwnershipToFallback_noChallenge_reverts() external { vm.prank(fallbackOwner); vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); } function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Try to execute before response period expires vm.prank(fallbackOwner); vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); } function test_changeOwnershipToFallback_moduleDisabledAtSafeLevel_reverts() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -296,14 +294,14 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Try to execute ownership transfer - should revert because module is disabled at Safe level vm.prank(fallbackOwner); vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); } function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Start a challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -312,11 +310,11 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { address randomCaller = makeAddr("randomCaller"); vm.prank(randomCaller); vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); // Execute from fallback owner - should succeed vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); // Verify ownership changed address[] memory newOwners = safeInstance.safe.getOwners(); @@ -328,11 +326,11 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Start and execute first challenge vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); vm.prank(fallbackOwner); - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + livenessModule2.changeOwnershipToFallback(safeInstance.safe); // Re-configure the module vm.prank(address(safeInstance.safe)); @@ -342,9 +340,9 @@ contract SaferSafes_ChangeOwnershipToFallback_Test is SaferSafes_TestInit { // Start a new challenge (as fallback owner) vm.prank(fallbackOwner); - livenessModule2.challenge(address(safeInstance.safe)); + livenessModule2.challenge(safeInstance.safe); - uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertGt(challengeEndTime, 0); } } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 2dcefe87d41cf..7e21f25129583 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -265,7 +265,7 @@ abstract contract TimelockGuard_TestInit is Test, SafeTestTools { contract TimelockGuard_TimelockConfiguration_Test is TimelockGuard_TestInit { /// @notice Ensures an unconfigured Safe reports a zero timelock delay. function test_timelockConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { - uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); + uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, 0); // configured is now determined by timelockDelay == 0 assertEq(delay == 0, true); @@ -274,7 +274,7 @@ contract TimelockGuard_TimelockConfiguration_Test is TimelockGuard_TestInit { /// @notice Validates the configuration view reflects the stored timelock delay. function test_timelockConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); + uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -291,7 +291,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 delay = timelockGuard.timelockConfiguration(safe); + uint256 delay = timelockGuard.timelockDelay(safe); assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -322,7 +322,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - uint256 delay = timelockGuard.timelockConfiguration(safe); + uint256 delay = timelockGuard.timelockDelay(safe); assertEq(delay, ONE_YEAR); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -332,7 +332,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockConfiguration(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.timelockDelay(safe), TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; @@ -350,14 +350,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.timelockConfiguration(safe), newDelay); + assertEq(timelockGuard.timelockDelay(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.timelockConfiguration(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.timelockDelay(safe), TIMELOCK_DELAY); // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); @@ -366,7 +366,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 - assertEq(timelockGuard.timelockConfiguration(safe), 0); + assertEq(timelockGuard.timelockDelay(safe), 0); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -382,12 +382,6 @@ 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 @@ -433,7 +427,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.timelockConfiguration(unguardedSafe.safe), 0); + assertEq(timelockGuard.timelockDelay(unguardedSafe.safe), 0); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); @@ -935,7 +929,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { ); // Verify configuration is cleared - assertEq(timelockGuard.timelockConfiguration(safe), 0); + assertEq(timelockGuard.timelockDelay(safe), 0); assertEq(timelockGuard.cancellationThreshold(safe), 0); // Verify pending transaction was cancelled @@ -972,7 +966,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.pendingTransactions(Safe(payable(address(safeInstance.safe)))).length, 50); // Verify configuration is cleared - assertEq(timelockGuard.timelockConfiguration(safe), 0); + assertEq(timelockGuard.timelockDelay(safe), 0); assertEq(timelockGuard.cancellationThreshold(safe), 0); } diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index 42ef9288d8e17..56ed0f177bae2 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -67,12 +67,12 @@ contract DeployOwnershipTest is Test, DeployOwnership { // LivenessModule2 checks LivenessModuleConfig memory lmConfig = exampleSecurityCouncilConfig.livenessModuleConfig; - (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).livenessSafeConfiguration(address(securityCouncilSafe)); - assertEq(configuredPeriod, lmConfig.livenessInterval); - assertEq(configuredFallback, lmConfig.fallbackOwner); + LivenessModule2.ModuleConfig memory moduleConfig = + LivenessModule2(livenessModule).livenessSafeConfiguration(Safe(payable(securityCouncilSafe))); + assertEq(moduleConfig.livenessResponsePeriod, lmConfig.livenessInterval); + assertEq(moduleConfig.fallbackOwner, lmConfig.fallbackOwner); // Verify no active challenge exists initially - assertEq(LivenessModule2(livenessModule).getChallengePeriodEnd(address(securityCouncilSafe)), 0); + assertEq(LivenessModule2(livenessModule).getChallengePeriodEnd(Safe(payable(securityCouncilSafe))), 0); } } From 54585b763346a0336fa404a0a75d4c15cc5f4ae3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 15:36:32 -0400 Subject: [PATCH 79/90] fix import and abi snapshot --- packages/contracts-bedrock/snapshots/abi/SaferSafes.json | 2 +- packages/contracts-bedrock/test/safe/SaferSafes.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json index fa401d9f82a2f..64e2890555030 100644 --- a/packages/contracts-bedrock/snapshots/abi/SaferSafes.json +++ b/packages/contracts-bedrock/snapshots/abi/SaferSafes.json @@ -567,7 +567,7 @@ "type": "address" } ], - "name": "timelockConfiguration", + "name": "timelockDelay", "outputs": [ { "internalType": "uint256", diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 36b9532d40ad6..49c10f91212bb 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -7,9 +7,9 @@ import "test/safe-tools/SafeTestTools.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; -import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { GuardManager, Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; -import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; // Import the test utils from LivenessModule2 tests import { LivenessModule2_TestUtils } from "test/safe/LivenessModule2.t.sol"; From eb8b081c0e1736a2fa8964008b766803eecce2ec Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 20:02:25 -0400 Subject: [PATCH 80/90] fix: off by one error in challenge period test --- packages/contracts-bedrock/test/safe/LivenessModule2.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 24680065089e2..91b8ca7c2586b 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -494,7 +494,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI /// @notice Tests that changeOwnershipToFallback reverts if called before response period expires function testFuzz_changeOwnershipToFallback_beforeResponsePeriod_reverts(uint256 timeElapsed) external { // Bound time to be within response period (not yet expired) - timeElapsed = bound(timeElapsed, 0, CHALLENGE_PERIOD); + timeElapsed = bound(timeElapsed, 0, CHALLENGE_PERIOD - 1); // Start a challenge vm.prank(fallbackOwner); From 8b65cd836d2bfd04573eb475c36f473f302bf00d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 21 Oct 2025 06:32:53 -0400 Subject: [PATCH 81/90] fix test name --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 7e21f25129583..1cd1331ccc5e1 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -260,9 +260,9 @@ abstract contract TimelockGuard_TestInit is Test, SafeTestTools { } } -/// @title TimelockGuard_TimelockConfiguration_Test +/// @title TimelockGuard_TimelockDelay_Test /// @notice Tests for timelockConfiguration function -contract TimelockGuard_TimelockConfiguration_Test is TimelockGuard_TestInit { +contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { /// @notice Ensures an unconfigured Safe reports a zero timelock delay. function test_timelockConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); From 8fe6f50915f6b58a215031da8e4082538c461970 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 14:13:07 -0400 Subject: [PATCH 82/90] refactor(test): split clearLivenessModule tests into separate contract - Create LivenessModule2_ClearLivenessModule_Test contract - Rename test_clear_* to test_clearLivenessModule_* for consistency - Update contract title to match function under test --- .../test/safe/LivenessModule2.t.sol | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 91b8ca7c2586b..73fed83415fac 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -108,8 +108,8 @@ contract LivenessModule2_TestInit is LivenessModule2_TestUtils { } } -/// @title LivenessModule2_Configure_Test -/// @notice Tests configuring and clearing the module +/// @title LivenessModule2_ConfigureLivenessModule_Test +/// @notice Tests configuring the module contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestInit { function test_configureLivenessModule_succeeds() external { vm.expectEmit(true, true, true, true); @@ -220,7 +220,12 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni assertEq(challengeEndTime, 0); } - function test_clear_succeeds() external { +} + +/// @title LivenessModule2_ClearLivenessModule_Test +/// @notice Tests clearing the module configuration +contract LivenessModule2_ClearLivenessModule_Test is LivenessModule2_TestInit { + function test_clearLivenessModule_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // First disable the module at the Safe level @@ -249,13 +254,13 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni assertEq(clearedConfig.fallbackOwner, address(0)); } - function test_clear_notEnabled_reverts() external { + function test_clearLivenessModule_notConfigured_reverts() external { vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeInstance.safe)); livenessModule2.clearLivenessModule(); } - function test_clear_moduleStillEnabled_reverts() external { + function test_clearLivenessModule_moduleStillEnabled_reverts() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Try to clear while module is still enabled (should revert) From 50f122d08755a4fe473dbbcd1c44246963922aaf Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 14:23:48 -0400 Subject: [PATCH 83/90] test(LivenessModule2): add challenge cancellation to clearLivenessModule test Enhance test_clearLivenessModule_succeeds to verify that clearing also properly cancels any active challenge, covering the _cancelChallenge code path. --- .../contracts-bedrock/test/safe/LivenessModule2.t.sol | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 73fed83415fac..e40dc9400d77e 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -228,6 +228,11 @@ contract LivenessModule2_ClearLivenessModule_Test is LivenessModule2_TestInit { function test_clearLivenessModule_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + // Start a challenge to test that clearing also cancels it + vm.prank(fallbackOwner); + livenessModule2.challenge(safeInstance.safe); + assertGt(livenessModule2.challengeStartTime(safeInstance.safe), 0); + // First disable the module at the Safe level SafeTestLib.execTransaction( safeInstance, @@ -237,6 +242,9 @@ contract LivenessModule2_ClearLivenessModule_Test is LivenessModule2_TestInit { Enum.Operation.Call ); + // Clear should emit ChallengeCancelled then ModuleCleared + vm.expectEmit(true, true, true, true); + emit ChallengeCancelled(address(safeInstance.safe)); vm.expectEmit(true, true, true, true); emit ModuleCleared(address(safeInstance.safe)); @@ -252,6 +260,7 @@ contract LivenessModule2_ClearLivenessModule_Test is LivenessModule2_TestInit { LivenessModule2.ModuleConfig memory clearedConfig = livenessModule2.livenessSafeConfiguration(safeInstance.safe); assertEq(clearedConfig.livenessResponsePeriod, 0); assertEq(clearedConfig.fallbackOwner, address(0)); + assertEq(livenessModule2.challengeStartTime(safeInstance.safe), 0); } function test_clearLivenessModule_notConfigured_reverts() external { From e6718e9913f2bdce964659ceb8c618560adbc5cb Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 14:38:24 -0400 Subject: [PATCH 84/90] test(LivenessModule2): add guard removal check to ownership transfer test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate guard removal verification into existing fuzz test rather than creating a separate test. Verifies that any guard set on the Safe is properly removed during the ownership transfer process. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../test/safe/LivenessModule2.t.sol | 16 +++++++++++++++- .../contracts-bedrock/test/safe/SaferSafes.t.sol | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index e40dc9400d77e..c73e37e03fdbb 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -10,6 +10,7 @@ import { Constants } from "src/libraries/Constants.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; /// @title LivenessModule2_TestUtils /// @notice Reusable helper methods for LivenessModule2 tests. @@ -219,7 +220,6 @@ contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestIni challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, 0); } - } /// @title LivenessModule2_ClearLivenessModule_Test @@ -464,6 +464,17 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Bound time to reasonable values (1 second to 365 days after expiry) timeAfterExpiry = bound(timeAfterExpiry, 1, 365 days); + // Set a guard to verify it gets removed + address mockGuard = makeAddr("mockGuard"); + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (mockGuard)), + Enum.Operation.Call + ); + assertEq(_getGuard(safeInstance), mockGuard); + // Start a challenge vm.prank(fallbackOwner); livenessModule2.challenge(safeInstance.safe); @@ -487,6 +498,9 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Verify challenge is reset uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(safeInstance.safe); assertEq(challengeEndTime, 0); + + // Verify guard was removed + assertEq(_getGuard(safeInstance), address(0)); } /// @notice Tests that changeOwnershipToFallback reverts if module is not configured diff --git a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol index 49c10f91212bb..778ed9b62ec20 100644 --- a/packages/contracts-bedrock/test/safe/SaferSafes.t.sol +++ b/packages/contracts-bedrock/test/safe/SaferSafes.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.15; import { Enum } from "safe-contracts/common/Enum.sol"; +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import "test/safe-tools/SafeTestTools.sol"; import { SaferSafes } from "src/safe/SaferSafes.sol"; From c6a1e3b955d6e3ec4073672a4d132e97e083a4ae Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 15:04:44 -0400 Subject: [PATCH 85/90] Remove pointless check Slop that got through --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 1cd1331ccc5e1..e164226510ca5 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -267,8 +267,6 @@ contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { function test_timelockConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, 0); - // configured is now determined by timelockDelay == 0 - assertEq(delay == 0, true); } /// @notice Validates the configuration view reflects the stored timelock delay. @@ -276,8 +274,6 @@ contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, TIMELOCK_DELAY); - // configured is now determined by timelockDelay != 0 - assertEq(delay != 0, true); } } @@ -293,8 +289,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { uint256 delay = timelockGuard.timelockDelay(safe); assertEq(delay, TIMELOCK_DELAY); - // configured is now determined by timelockDelay != 0 - assertEq(delay != 0, true); } /// @notice Confirms delays above the maximum revert during configuration. @@ -324,8 +318,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { uint256 delay = timelockGuard.timelockDelay(safe); assertEq(delay, ONE_YEAR); - // configured is now determined by timelockDelay != 0 - assertEq(delay != 0, true); } /// @notice Demonstrates the guard can be reconfigured to a new delay. From ea5b5b0af767044b5de9fda62f8c00949595a7c7 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 15:04:56 -0400 Subject: [PATCH 86/90] Convert to fuzz tests --- .../test/safe/TimelockGuard.t.sol | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index e164226510ca5..d6250d56b3b40 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -280,24 +280,26 @@ contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { /// @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 { + /// @notice Verifies the guard can be configured with various valid delays. + function testFuzz_configureTimelockGuard_validDelay_succeeds(uint256 _delay) external { + _delay = bound(_delay, 1, ONE_YEAR); + vm.expectEmit(true, true, true, true); - emit GuardConfigured(safe, TIMELOCK_DELAY); + emit GuardConfigured(safe, _delay); - _configureGuard(safeInstance, TIMELOCK_DELAY); + _configureGuard(safeInstance, _delay); uint256 delay = timelockGuard.timelockDelay(safe); - assertEq(delay, TIMELOCK_DELAY); + assertEq(delay, _delay); } /// @notice Confirms delays above the maximum revert during configuration. - function test_configureTimelockGuard_revertsIfDelayTooLong_reverts() external { - uint256 tooLongDelay = ONE_YEAR + 1; + function testFuzz_configureTimelockGuard_delayTooLong_reverts(uint256 _delay) external { + _delay = bound(_delay, ONE_YEAR + 1, type(uint256).max); vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); vm.prank(address(safeInstance.safe)); - timelockGuard.configureTimelockGuard(tooLongDelay); + timelockGuard.configureTimelockGuard(_delay); } /// @notice Checks configuration reverts when the contract is too old. From ad3633d9312bbd18903b3aa514f43beb36650ba1 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 15:18:32 -0400 Subject: [PATCH 87/90] test(TimelockGuard): enhance test coverage and convert to fuzz tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert configureTimelockGuard tests to fuzz tests for broader coverage - Add comprehensive checkAfterExecution tests for all code paths - Remove redundant assertions and comments - Add test to verify transaction cannot be re-executed after success - Improve test documentation and structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../test/safe/TimelockGuard.t.sol | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index d6250d56b3b40..3c2833435793e 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -727,6 +727,77 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { } } +/// @title TimelockGuard_CheckAfterExecution_Test +/// @notice Tests for checkAfterExecution function +contract TimelockGuard_CheckAfterExecution_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY); + } + + /// @notice Verifies successful execution updates state and resets threshold. + function test_checkAfterExecution_successfulExecution_succeeds() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + uint256 expectedExecutionTime = block.timestamp + TIMELOCK_DELAY; + vm.warp(expectedExecutionTime); + + // Verify initial cancellation threshold + uint256 initialThreshold = timelockGuard.cancellationThreshold(safeInstance.safe); + assertEq(initialThreshold, 1); + + // Call checkAfterExecution with successful execution + vm.expectEmit(true, true, true, true); + emit TransactionExecuted(safeInstance.safe, dummyTx.hash); + vm.prank(address(safeInstance.safe)); + timelockGuard.checkAfterExecution(dummyTx.hash, true); + + // Verify transaction state changed to Executed + TimelockGuard.ScheduledTransaction memory scheduledTx = + timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); + assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Executed)); + + // Verify transaction removed from pending list + TimelockGuard.ScheduledTransaction[] memory pending = timelockGuard.pendingTransactions(safeInstance.safe); + assertEq(pending.length, 0); + + // Verify cancellation threshold was reset to 1 + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); + + // Verify transaction cannot be executed again + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyExecuted.selector); + dummyTx.executeTransaction(safeInstance.owners[0]); + } + + /// @notice Verifies transaction state remains unchanged on execution failure. + function test_checkAfterExecution_failedExecution_succeeds() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + uint256 expectedExecutionTime = block.timestamp + TIMELOCK_DELAY; + vm.warp(expectedExecutionTime); + + // Call checkAfterExecution with failed execution + vm.prank(address(safeInstance.safe)); + timelockGuard.checkAfterExecution(dummyTx.hash, false); + + // Verify transaction state remains unchanged + TimelockGuard.ScheduledTransaction memory scheduledTx = + timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); + assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Pending)); + assertEq(uint256(scheduledTx.executionTime), expectedExecutionTime); + + } + + /// @notice Verifies unconfigured guard allows checkAfterExecution. + function test_checkAfterExecution_unconfiguredGuard_succeeds() external { + bytes32 randomHash = keccak256("random"); + vm.prank(address(unguardedSafe.safe)); + timelockGuard.checkAfterExecution(randomHash, true); + } +} + /// @title TimelockGuard_MaxCancellationThreshold_Test /// @notice Tests for the maxCancellationThreshold function in TimelockGuard contract TimelockGuard_MaxCancellationThreshold_Test is TimelockGuard_TestInit { From 2f940e03986ad040b6f5403503fb52199d93a032 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 20 Oct 2025 15:23:31 -0400 Subject: [PATCH 88/90] fix test name to match function name --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 3c2833435793e..c13a2e2dd345e 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -261,16 +261,16 @@ abstract contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @title TimelockGuard_TimelockDelay_Test -/// @notice Tests for timelockConfiguration function +/// @notice Tests for TimelockDelay function contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { /// @notice Ensures an unconfigured Safe reports a zero timelock delay. - function test_timelockConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { + function test_timelockDelay_returnsZeroForUnconfiguredSafe_succeeds() external view { uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, 0); } /// @notice Validates the configuration view reflects the stored timelock delay. - function test_timelockConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { + function test_timelockDelay_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); assertEq(delay, TIMELOCK_DELAY); @@ -787,7 +787,6 @@ contract TimelockGuard_CheckAfterExecution_Test is TimelockGuard_TestInit { timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); assertEq(uint256(scheduledTx.state), uint256(TimelockGuard.TransactionState.Pending)); assertEq(uint256(scheduledTx.executionTime), expectedExecutionTime); - } /// @notice Verifies unconfigured guard allows checkAfterExecution. From b6acc71b18d23e0fa86dc7c9a7b55229c1644368 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 21 Oct 2025 19:44:37 +0200 Subject: [PATCH 89/90] feat: two extra tests to fuzz --- .../test/safe/TimelockGuard.t.sol | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index c13a2e2dd345e..ad9227bbaa17c 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -269,11 +269,12 @@ contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { assertEq(delay, 0); } - /// @notice Validates the configuration view reflects the stored timelock delay. - function test_timelockDelay_returnsConfigurationForConfiguredSafe_succeeds() external { - _configureGuard(safeInstance, TIMELOCK_DELAY); + /// @notice Fuzz test: Validates the configuration view reflects the stored timelock delay for any valid delay. + function testFuzz_timelockDelay_returnsConfigurationForConfiguredSafe_succeeds(uint256 _delay) external { + _delay = bound(_delay, 1, ONE_YEAR); // Restrict to valid range + _configureGuard(safeInstance, _delay); uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); - assertEq(delay, TIMELOCK_DELAY); + assertEq(delay, _delay); } } @@ -789,11 +790,10 @@ contract TimelockGuard_CheckAfterExecution_Test is TimelockGuard_TestInit { assertEq(uint256(scheduledTx.executionTime), expectedExecutionTime); } - /// @notice Verifies unconfigured guard allows checkAfterExecution. - function test_checkAfterExecution_unconfiguredGuard_succeeds() external { - bytes32 randomHash = keccak256("random"); + /// @notice Fuzz test: Verifies unconfigured guard allows checkAfterExecution for any _hash. + function testFuzz_checkAfterExecution_unconfiguredGuard_succeeds(bytes32 _hash) external { vm.prank(address(unguardedSafe.safe)); - timelockGuard.checkAfterExecution(randomHash, true); + timelockGuard.checkAfterExecution(_hash, true); } } From af493af5e317c5b4228302ad45dbc6aee294e1e2 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 21 Oct 2025 19:48:42 +0200 Subject: [PATCH 90/90] fix: fmt --- .../contracts-bedrock/test/safe/TimelockGuard.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index ad9227bbaa17c..2e07f1842ce65 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -270,11 +270,11 @@ contract TimelockGuard_TimelockDelay_Test is TimelockGuard_TestInit { } /// @notice Fuzz test: Validates the configuration view reflects the stored timelock delay for any valid delay. - function testFuzz_timelockDelay_returnsConfigurationForConfiguredSafe_succeeds(uint256 _delay) external { - _delay = bound(_delay, 1, ONE_YEAR); // Restrict to valid range - _configureGuard(safeInstance, _delay); - uint256 delay = timelockGuard.timelockDelay(safeInstance.safe); - assertEq(delay, _delay); + function testFuzz_timelockDelay_returnsConfigurationForConfiguredSafe_succeeds(uint256 _delay_) external { + _delay_ = bound(_delay_, 1, ONE_YEAR); // Restrict to valid range + _configureGuard(safeInstance, _delay_); + uint256 delay_ = timelockGuard.timelockDelay(safeInstance.safe); + assertEq(delay_, _delay_); } }