From 9d1322434f4eaffd46002627e06d70cb4dfce922 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Thu, 28 Aug 2025 20:16:58 +0200 Subject: [PATCH 01/45] start --- .../interfaces/safe/ILivenessModule2.sol | 88 +++ .../src/safe/LivenessModule2.sol | 253 ++++++++ .../test/safe/LivenessModule2.t.sol | 555 ++++++++++++++++++ 3 files changed, 896 insertions(+) create mode 100644 packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol create mode 100644 packages/contracts-bedrock/src/safe/LivenessModule2.sol create mode 100644 packages/contracts-bedrock/test/safe/LivenessModule2.t.sol diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol new file mode 100644 index 00000000000..4558230f87c --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -0,0 +1,88 @@ +// 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 { + + /// @notice Error for when module is not enabled for the Safe + error LivenessModule2_ModuleNotEnabled(); + + /// @notice Error for when module is already enabled for the Safe + error LivenessModule2_ModuleAlreadyEnabled(); + + /// @notice Error for when a challenge already exists + error LivenessModule2_ChallengeAlreadyExists(); + + /// @notice Error for when no challenge exists + error LivenessModule2_ChallengeDoesNotExist(); + + /// @notice Error for when challenge is not successful + error LivenessModule2_ChallengeNotSuccessful(); + + /// @notice Error for when caller is not authorized + error LivenessModule2_UnauthorizedCaller(); + + /// @notice Error for invalid parameters + error LivenessModule2_InvalidParameters(); + + /// @notice Error for when an owner is not found in the Safe's owner list + error LivenessModule2_OwnerNotFound(); + + /// @notice Emitted when a Safe enables the module + event ModuleEnabled(address indexed safe, uint256 livenessChallengePeriod, address fallbackOwner); + + /// @notice Emitted when a Safe disables the module + event ModuleDisabled(address indexed safe); + + /// @notice Emitted when a challenge is started + event ChallengeStarted(address indexed safe, uint256 challengeStartTime); + + /// @notice Emitted when a challenge is cancelled + event ChallengeCancelled(address indexed safe); + + /// @notice Emitted when ownership is transferred to the fallback owner + event ChallengeExecuted(address indexed safe, address fallbackOwner); + + /// @notice Returns the configuration for a Safe + /// @return livenessChallengePeriod The challenge period + /// @return fallbackOwner The fallback owner address + /// @return challengeStartTime The challenge start time (0 if no challenge) + function safeConfigs(address) external view returns (uint256 livenessChallengePeriod, address fallbackOwner, uint256 challengeStartTime); + + /// @notice Semantic version + /// @return version The contract version + function version() external view returns (string memory); + + /// @notice Enables the module by the multisig to be challenged + /// @param _livenessChallengePeriod The period in seconds for a liveness challenge + /// @param _fallbackOwner The address that will become owner if challenge succeeds + function enableModule(uint256 _livenessChallengePeriod, address _fallbackOwner) external; + + /// @notice Disables the module by an enabled safe + function disableModule() external; + + /// @notice Returns the liveness_challenge_period and fallback_owner for a given safe + /// @param _safe The Safe address to query + /// @return livenessChallengePeriod The challenge period + /// @return fallbackOwner The fallback owner address + function viewConfiguration(address _safe) external view returns (uint256, address); + + /// @notice Returns challenge_start_time + liveness_challenge_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 isChallenged(address _safe) external view returns (uint256); + + /// @notice Challenges an enabled safe + /// @param _safe The Safe to challenge + function startChallenge(address _safe) external; + + /// @notice Cancels a challenge for an enabled safe + function cancelChallenge() 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/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol new file mode 100644 index 00000000000..59cef2e5c73 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Safe +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; +import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; + +// Interfaces +import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; + +/// @title LivenessModule2 +/// @notice This module allows for challenge-based ownership transfer to a fallback owner +/// when the Safe becomes unresponsive. The fallback owner can initiate a challenge, +/// and if the Safe doesn't respond within the challenge period, ownership transfers +/// to the fallback owner. +/// @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 enableModule() with parameters +contract LivenessModule2 is ILivenessModule2 { + /// @notice Configuration for a Safe's liveness module + struct SafeConfig { + uint256 livenessChallengePeriod; + address fallbackOwner; + uint256 challengeStartTime; + } + + /// @notice Mapping from Safe address to its configuration + mapping(address => SafeConfig) public safeConfigs; + + /// @notice Semantic version. + /// @custom:semver 2.0.0 + string public constant version = "2.0.0"; + + /// @notice Enables the module by the multisig to be challenged and sets the liveness_challenge_period and + /// fallback_owner + /// @dev MUST set the caller as a safe + /// @dev MUST take as parameters liveness_challenge_period and fallback_owner and store them as related to the safe + /// @dev MUST accept an arbitrary number of independent safe contracts to enable the module + /// @param _livenessChallengePeriod The period in seconds for a liveness challenge + /// @param _fallbackOwner The address that will become owner if challenge succeeds + function enableModule(uint256 _livenessChallengePeriod, address _fallbackOwner) external { + if (_livenessChallengePeriod == 0 || _fallbackOwner == address(0)) { + revert LivenessModule2_InvalidParameters(); + } + + // Set the caller as a safe and store its configuration + SafeConfig storage config = safeConfigs[msg.sender]; + if (config.fallbackOwner != address(0)) { + revert LivenessModule2_ModuleAlreadyEnabled(); + } + + // Store the parameters related to this safe + config.livenessChallengePeriod = _livenessChallengePeriod; + config.fallbackOwner = _fallbackOwner; + + emit ModuleEnabled(msg.sender, _livenessChallengePeriod, _fallbackOwner); + } + + /// @notice Disables the module by an enabled safe + /// @dev MUST only be executable by an enabled safe + /// @dev MUST erase the existing liveness_challenge_period and fallback_owner data related to the calling safe + function disableModule() external { + SafeConfig storage config = safeConfigs[msg.sender]; + // Check if the calling safe has the module enabled + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotEnabled(); + } + + // Erase the configuration data for this safe + delete safeConfigs[msg.sender]; + emit ModuleDisabled(msg.sender); + } + + /// @notice Returns the liveness_challenge_period and fallback_owner for a given safe + /// @dev MUST never revert + /// @param _safe The Safe address to query + /// @return livenessChallengePeriod The challenge period + /// @return fallbackOwner The fallback owner address + function viewConfiguration(address _safe) external view returns (uint256, address) { + SafeConfig storage config = safeConfigs[_safe]; + return (config.livenessChallengePeriod, config.fallbackOwner); + } + + /// @notice Returns challenge_start_time + liveness_challenge_period if there is a challenge for the given safe, or + /// 0 if not + /// @dev MUST never revert + /// @param _safe The Safe address to query + /// @return The challenge end timestamp, or 0 if no challenge + function isChallenged(address _safe) external view returns (uint256) { + SafeConfig storage config = safeConfigs[_safe]; + if (config.challengeStartTime == 0) { + return 0; + } + return config.challengeStartTime + config.livenessChallengePeriod; + } + + /// @notice Challenges an enabled safe + /// @dev MUST only be executable by fallback owner of the challenged safe + /// @dev MUST revert if there is a challenge for the safe + /// @dev MUST set challenge_start_time to the current block time + /// @dev MUST emit the ChallengeStarted event + /// @param _safe The Safe to challenge + function startChallenge(address _safe) external { + SafeConfig storage config = safeConfigs[_safe]; + + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotEnabled(); + } + + if (msg.sender != config.fallbackOwner) { + revert LivenessModule2_UnauthorizedCaller(); + } + + if (config.challengeStartTime != 0) { + revert LivenessModule2_ChallengeAlreadyExists(); + } + + config.challengeStartTime = block.timestamp; + emit ChallengeStarted(_safe, block.timestamp); + } + + /// @notice Cancels a challenge for an enabled safe + /// @dev MUST only be executable by an enabled safe + /// @dev MUST revert if there isn't a challenge for the calling safe + /// @dev MUST revert if there is a challenge for the calling safe but the challenge is successful + /// @dev MUST emit the ChallengeCancelled event + function cancelChallenge() external { + SafeConfig storage config = safeConfigs[msg.sender]; + + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotEnabled(); + } + + if (config.challengeStartTime == 0) { + revert LivenessModule2_ChallengeDoesNotExist(); + } + + // Check if challenge period has expired + if (block.timestamp >= config.challengeStartTime + config.livenessChallengePeriod) { + revert LivenessModule2_ChallengeNotSuccessful(); + } + + config.challengeStartTime = 0; + emit ChallengeCancelled(msg.sender); + } + + /// @notice With a successful challenge, removes all current owners from an enabled safe, appoints fallback as its + /// sole owner, and sets its quorum to 1 + /// @dev MUST be executable by anyone + /// @dev MUST revert if the given safe hasn't enabled the module + /// @dev MUST revert if there isn't a successful challenge for the given safe + /// @dev MUST enable the module to start a new challenge + /// @dev MUST emit the ChallengeExecuted event + /// @param _safe The Safe to transfer ownership of + function changeOwnershipToFallback(address _safe) external { + SafeConfig storage config = safeConfigs[_safe]; + + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotEnabled(); + } + + if (config.challengeStartTime == 0) { + revert LivenessModule2_ChallengeDoesNotExist(); + } + + // Check if challenge period has expired + if (block.timestamp < config.challengeStartTime + config.livenessChallengePeriod) { + revert LivenessModule2_ChallengeNotSuccessful(); + } + + Safe safe = Safe(payable(_safe)); + + // Get current owners + address[] memory currentOwners = safe.getOwners(); + + // Remove all current owners and add fallback owner + // We need to do this by swapping the first owner with the fallback owner, + // then removing the remaining owners + if (currentOwners.length > 0) { + // First, swap the first owner with the fallback owner + address prevOwner = address(0x1); // Sentinel value for the first owner in the Safe's linked list + safe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.swapOwner, (prevOwner, currentOwners[0], config.fallbackOwner)) + }); + + // Then remove all other owners if there are any + for (uint256 i = 1; i < currentOwners.length; i++) { + // After swapping, the fallback owner is now in the list + // We need to be careful about the linked list structure + address[] memory updatedOwners = safe.getOwners(); + address ownerToRemove = currentOwners[i]; + + // Find if this owner is still in the list (it should be unless already removed) + bool found = false; + for (uint256 j = 0; j < updatedOwners.length; j++) { + if (updatedOwners[j] == ownerToRemove) { + found = true; + break; + } + } + + if (found) { + // Find the previous owner in the linked list + address prev = _findPrevOwner(safe, ownerToRemove); + safe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.removeOwner, (prev, ownerToRemove, 1)) + }); + } + } + + // Set threshold to 1 + safe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.changeThreshold, (1)) + }); + } + + // Reset the challenge state to allow a new challenge + config.challengeStartTime = 0; + + emit ChallengeExecuted(_safe, config.fallbackOwner); + } + + /// @notice Helper function to find the previous owner in the Safe's linked list + /// @param _safe The Safe contract + /// @param _owner The owner to find the previous of + /// @return The previous owner address + function _findPrevOwner(Safe _safe, address _owner) private view returns (address) { + address[] memory owners = _safe.getOwners(); + + // Sentinel address is used as the previous for the first owner + address prev = address(0x1); + + for (uint256 i = 0; i < owners.length; i++) { + if (owners[i] == _owner) { + return prev; + } + prev = owners[i]; + } + + // This should not happen if the owner exists + revert LivenessModule2_OwnerNotFound(); + } +} diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol new file mode 100644 index 00000000000..ea4df524031 --- /dev/null +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -0,0 +1,555 @@ +// 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"; + +import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; + +/// @title LivenessModule2_TestInit +/// @notice Reusable test initialization for `LivenessModule2` tests. +contract LivenessModule2_TestInit is Test, SafeTestTools { + using SafeTestLib for SafeInstance; + + // Events + event ModuleEnabled(address indexed safe, uint256 livenessChallengePeriod, address fallbackOwner); + event ModuleDisabled(address indexed safe); + event ChallengeStarted(address indexed safe, uint256 challengeStartTime); + event ChallengeCancelled(address indexed safe); + event ChallengeExecuted(address indexed safe, address fallbackOwner); + + uint256 constant INIT_TIME = 10; + uint256 constant CHALLENGE_PERIOD = 7 days; + uint256 constant NUM_OWNERS = 5; + uint256 constant THRESHOLD = 3; + + LivenessModule2 livenessModule2; + SafeInstance safeInstance; + address fallbackOwner; + address[] owners; + uint256[] ownerPKs; + + function setUp() public virtual { + vm.warp(INIT_TIME); + + // Deploy the singleton LivenessModule2 + livenessModule2 = new LivenessModule2(); + + // 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 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 { + SafeTestLib.execTransaction( + _safe, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.enableModule, (_period, _fallback)), + Enum.Operation.Call + ); + } + + /// @notice Helper to disable the LivenessModule2 for a Safe + function _disableModule(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.disableModule, ()), Enum.Operation.Call + ); + } + + /// @notice Helper to cancel a challenge from a Safe + function _cancelChallenge(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.cancelChallenge, ()), Enum.Operation.Call + ); + } +} + +/// @title LivenessModule2_EnableModule_Test +/// @notice Tests enabling and disabling the module +contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { + function test_enableModule_succeeds() external { + vm.expectEmit(true, true, true, true); + emit ModuleEnabled(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); + + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + (uint256 period, address fbOwner) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + assertEq(period, CHALLENGE_PERIOD); + assertEq(fbOwner, fallbackOwner); + } + + function test_enableModule_acceptsArbitraryNumberOfSafes_succeeds() external { + // Test that multiple independent safes can enable the module + address safe1 = makeAddr("safe1"); + address safe2 = makeAddr("safe2"); + address safe3 = makeAddr("safe3"); + + address fallback1 = makeAddr("fallback1"); + address fallback2 = makeAddr("fallback2"); + address fallback3 = makeAddr("fallback3"); + + // Enable module for safe1 + vm.prank(safe1); + livenessModule2.enableModule(1 days, fallback1); + + // Enable module for safe2 + vm.prank(safe2); + livenessModule2.enableModule(2 days, fallback2); + + // Enable module for safe3 + vm.prank(safe3); + livenessModule2.enableModule(3 days, fallback3); + + // Verify each safe has independent configuration + (uint256 period1, address fb1) = livenessModule2.viewConfiguration(safe1); + assertEq(period1, 1 days); + assertEq(fb1, fallback1); + + (uint256 period2, address fb2) = livenessModule2.viewConfiguration(safe2); + assertEq(period2, 2 days); + assertEq(fb2, fallback2); + + (uint256 period3, address fb3) = livenessModule2.viewConfiguration(safe3); + assertEq(period3, 3 days); + assertEq(fb3, fallback3); + } + + function test_enableModule_requiresSafeModuleInstallation_reverts() external { + // Create a safe that has NOT installed the module at the Safe level + (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("newSafe", NUM_OWNERS); + SafeInstance memory newSafe = _setupSafe(newKeys, THRESHOLD); + // Note: we don't call SafeTestLib.enableModule here + + // Configure the module (this should work even without Safe-level installation) + _enableModule(newSafe, CHALLENGE_PERIOD, fallbackOwner); + + // Verify configuration was stored + (uint256 period, address fb) = livenessModule2.viewConfiguration(address(newSafe.safe)); + assertEq(period, CHALLENGE_PERIOD); + assertEq(fb, fallbackOwner); + + // But trying to execute a challenge should fail because the Safe hasn't + // enabled this module at the Safe level (so execTransactionFromModule will fail) + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(newSafe.safe)); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // This should fail because the module is not enabled at the Safe level + // The Safe will revert with "GS104" when execTransactionFromModule is called by non-enabled module + vm.expectRevert("GS104"); + livenessModule2.changeOwnershipToFallback(address(newSafe.safe)); + } + + function test_enableModule_alreadyEnabled_reverts() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Try to enable again directly from the Safe address + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleAlreadyEnabled.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.enableModule(CHALLENGE_PERIOD, fallbackOwner); + } + + function test_enableModule_invalidParameters_reverts() external { + // Test with zero period + vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.enableModule(0, fallbackOwner); + + // Test with zero address + vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.enableModule(CHALLENGE_PERIOD, address(0)); + } + + function test_disableModule_succeeds() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + vm.expectEmit(true, true, true, true); + emit ModuleDisabled(address(safeInstance.safe)); + + _disableModule(safeInstance); + + (uint256 period, address fbOwner) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + assertEq(period, 0); + assertEq(fbOwner, address(0)); + } + + function test_disableModule_notEnabled_reverts() external { + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.disableModule(); + } +} + +/// @title LivenessModule2_StartChallenge_Test +/// @notice Tests the challenge mechanism +contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { + function setUp() public override { + super.setUp(); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + } + + function test_startChallenge_succeeds() external { + vm.expectEmit(true, true, true, true); + emit ChallengeStarted(address(safeInstance.safe), block.timestamp); + + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + assertEq(challengeEndTime, block.timestamp + CHALLENGE_PERIOD); + } + + function test_startChallenge_notFallbackOwner_reverts() external { + address notFallback = makeAddr("notFallback"); + + vm.expectRevert(ILivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + vm.prank(notFallback); + livenessModule2.startChallenge(address(safeInstance.safe)); + } + + function test_startChallenge_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(fallbackOwner); + livenessModule2.startChallenge(newSafe); + } + + function test_startChallenge_alreadyExists_reverts() external { + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + } + + function test_cancelChallenge_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Cancel it + vm.expectEmit(true, true, true, true); + emit ChallengeCancelled(address(safeInstance.safe)); + + _cancelChallenge(safeInstance); + + // Verify challenge is cancelled + uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + } + + function test_cancelChallenge_noChallenge_reverts() external { + // Module is already enabled in setUp, no challenge exists + + // Try to cancel when no challenge exists - this should fail + // We need to use a transaction that would work if there was a challenge + // Use safeTxGas > 0 to allow the Safe to handle the revert gracefully + bytes memory data = abi.encodeCall(LivenessModule2.cancelChallenge, ()); + bool success = SafeTestLib.execTransaction( + safeInstance, + address(livenessModule2), + 0, + data, + Enum.Operation.Call, + 100000, // safeTxGas > 0 allows transaction to fail without reverting + 0, + 0, + address(0), + address(0), + "" + ); + assertFalse(success, "Should fail to cancel non-existent challenge"); + } + + function test_cancelChallenge_afterPeriod_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Try to cancel - should fail as challenge is successful + vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeNotSuccessful.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.cancelChallenge(); + } + + function test_cancelChallenge_moduleNotEnabled_reverts() external { + // Create a Safe that hasn't enabled the module + address safeThatDidntEnable = makeAddr("safeThatDidntEnable"); + + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(safeThatDidntEnable); + livenessModule2.cancelChallenge(); + } +} + +/// @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); + } + + function test_changeOwnershipToFallback_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeExecuted(address(safeInstance.safe), fallbackOwner); + + livenessModule2.changeOwnershipToFallback(address(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.isChallenged(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + } + + function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + livenessModule2.changeOwnershipToFallback(newSafe); + } + + function test_changeOwnershipToFallback_noChallenge_reverts() external { + vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_challengeNotSuccessful_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Try to execute before period expires + vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeNotSuccessful.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_canBeCalledByAnyone_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute from random address + address randomCaller = makeAddr("randomCaller"); + vm.prank(randomCaller); + 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_allowsNewChallenge_succeeds() external { + // Start and execute first challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Start a new challenge (as fallback owner) + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + assertGt(challengeEndTime, 0); + } +} + +/// @title LivenessModule2_ViewConfiguration_Test +/// @notice Tests view functions +contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { + function test_viewConfiguration_works() external { + // Before enabling + (uint256 period1, address fbOwner1) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + assertEq(period1, 0); + assertEq(fbOwner1, address(0)); + + // After enabling + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + (uint256 period2, address fbOwner2) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + assertEq(period2, CHALLENGE_PERIOD); + assertEq(fbOwner2, fallbackOwner); + } + + function test_isChallenged_works() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // No challenge + assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); + + // With challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); + + // After cancellation + _cancelChallenge(safeInstance); + assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); + } + + function test_version_works() external view { + assertTrue(bytes(livenessModule2.version()).length > 0); + } +} + +/// @title LivenessModule2_IsChallenged_Test +/// @notice Tests invariants from specifications +contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { + /// @notice Test iLM-001: No Concurrent Challenges + function test_invariant_noConcurrentChallenges_works() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Start first challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Attempt second challenge should fail + vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + } + + /// @notice Test iLM-002: Honest Users Can Recover From Temporary Key Control + function test_invariant_honestUsersCanRecover_works() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Simulate attacker with control over less than quorum + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Honest users (with quorum) can cancel the challenge + _cancelChallenge(safeInstance); + + // Verify Safe is still controlled by original owners + address[] memory currentOwners = safeInstance.safe.getOwners(); + assertEq(currentOwners.length, NUM_OWNERS); + for (uint256 i = 0; i < NUM_OWNERS; i++) { + bool found = false; + for (uint256 j = 0; j < owners.length; j++) { + if (currentOwners[i] == owners[j]) { + found = true; + break; + } + } + assertTrue(found); + } + } + + /// @notice Test iLM-003: A Quorum Of Honest Users Retains Ownership + function test_invariant_quorumRetainsOwnership_works() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Quorum of honest users cancels before period expires + vm.warp(block.timestamp + CHALLENGE_PERIOD - 1); + _cancelChallenge(safeInstance); + + // Verify ownership unchanged + address[] memory currentOwners = safeInstance.safe.getOwners(); + assertEq(currentOwners.length, NUM_OWNERS); + assertEq(safeInstance.safe.getThreshold(), THRESHOLD); + } +} + +/// @title LivenessModule2_CancelChallenge_Test +/// @notice Integration tests with Safe operations +contract LivenessModule2_CancelChallenge_Test is LivenessModule2_TestInit { + function test_integration_fullChallengeFlow_works() external { + // Enable module + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Start challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // Safe responds and cancels challenge + _cancelChallenge(safeInstance); + + // Start another challenge + vm.prank(fallbackOwner); + livenessModule2.startChallenge(address(safeInstance.safe)); + + // This time Safe doesn't respond + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify fallback owner has control + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + assertEq(safeInstance.safe.getThreshold(), 1); + } + + function test_integration_multipleEnable_works() external { + // Use different key sets for different safes + (, uint256[] memory keys1) = SafeTestLib.makeAddrsAndKeys("safe1", NUM_OWNERS); + (, uint256[] memory keys2) = SafeTestLib.makeAddrsAndKeys("safe2", NUM_OWNERS); + + SafeInstance memory safe1 = _setupSafe(keys1, THRESHOLD); + SafeInstance memory safe2 = _setupSafe(keys2, THRESHOLD); + + SafeTestLib.enableModule(safe1, address(livenessModule2)); + SafeTestLib.enableModule(safe2, address(livenessModule2)); + + address fallback1 = makeAddr("fallback1"); + address fallback2 = makeAddr("fallback2"); + + // Enable module on both safes + _enableModule(safe1, CHALLENGE_PERIOD, fallback1); + _enableModule(safe2, CHALLENGE_PERIOD * 2, fallback2); + + // Verify configurations are independent + (uint256 period1, address fb1) = livenessModule2.viewConfiguration(address(safe1.safe)); + (uint256 period2, address fb2) = livenessModule2.viewConfiguration(address(safe2.safe)); + + assertEq(period1, CHALLENGE_PERIOD); + assertEq(fb1, fallback1); + assertEq(period2, CHALLENGE_PERIOD * 2); + assertEq(fb2, fallback2); + } +} From 81024955f70ac2ea41ac7fbb95c9dadb6affe0cb Mon Sep 17 00:00:00 2001 From: JosepBove Date: Thu, 28 Aug 2025 20:17:51 +0200 Subject: [PATCH 02/45] DeployOwnership scripts update --- .../scripts/deploy/DeployOwnership.s.sol | 63 ++++++++----------- .../test/scripts/DeployOwnership.t.sol | 33 +++------- 2 files changed, 36 insertions(+), 60 deletions(-) diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 13914f167dc..3ed0582b674 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -12,8 +12,8 @@ import { Enum as SafeOps } from "safe-contracts/common/Enum.sol"; import { DeployUtils } from "scripts/libraries/DeployUtils.sol"; -import { LivenessGuard } from "src/safe/LivenessGuard.sol"; -import { LivenessModule } from "src/safe/LivenessModule.sol"; +import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; @@ -240,36 +240,14 @@ contract DeployOwnership is Deploy { }); } - /// @notice Deploy a LivenessGuard for use on the Security Council Safe. - /// Note this function does not have the broadcast modifier. - function deployLivenessGuard() public returns (address addr_) { - Safe councilSafe = Safe(payable(artifacts.mustGetAddress("SecurityCouncilSafe"))); - addr_ = address(new LivenessGuard(councilSafe)); - - artifacts.save("LivenessGuard", address(addr_)); - console.log("New LivenessGuard deployed at %s", address(addr_)); - } - - /// @notice Deploy a LivenessModule for use on the Security Council Safe + /// @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_) { - Safe councilSafe = Safe(payable(artifacts.mustGetAddress("SecurityCouncilSafe"))); - address guard = artifacts.mustGetAddress("LivenessGuard"); - LivenessModuleConfig memory livenessModuleConfig = _getExampleCouncilConfig().livenessModuleConfig; - - addr_ = address( - new LivenessModule({ - _safe: councilSafe, - _livenessGuard: LivenessGuard(guard), - _livenessInterval: livenessModuleConfig.livenessInterval, - _thresholdPercentage: livenessModuleConfig.thresholdPercentage, - _minOwners: livenessModuleConfig.minOwners, - _fallbackOwner: livenessModuleConfig.fallbackOwner - }) - ); + // Deploy the singleton LivenessModule2 (no parameters needed) + addr_ = address(new LivenessModule2()); - artifacts.save("LivenessModule", address(addr_)); - console.log("New LivenessModule deployed at %s", address(addr_)); + artifacts.save("LivenessModule2", address(addr_)); + console.log("New LivenessModule2 deployed at %s", address(addr_)); } /// @notice Deploy a Security Council Safe. @@ -319,11 +297,6 @@ contract DeployOwnership is Deploy { SecurityCouncilConfig memory exampleCouncilConfig = _getExampleCouncilConfig(); Safe safe = Safe(artifacts.mustGetAddress("SecurityCouncilSafe")); - // Deploy and add the Liveness Guard. - address guard = deployLivenessGuard(); - _callViaSafe({ _safe: safe, _target: address(safe), _data: abi.encodeCall(GuardManager.setGuard, (guard)) }); - console.log("LivenessGuard setup on SecurityCouncilSafe"); - // Deploy and add the Liveness Module. address livenessModule = deployLivenessModule(); _callViaSafe({ @@ -332,13 +305,29 @@ contract DeployOwnership is Deploy { _data: abi.encodeCall(ModuleManager.enableModule, (livenessModule)) }); + // Configure the LivenessModule2 (second step of installation) + LivenessModuleConfig memory livenessModuleConfig = exampleCouncilConfig.livenessModuleConfig; + _callViaSafe({ + _safe: safe, + _target: livenessModule, + _data: abi.encodeCall( + LivenessModule2.enableModule, (livenessModuleConfig.livenessInterval, livenessModuleConfig.fallbackOwner) + ) + }); + // Finalize configuration by removing the additional deployer key. removeDeployerFromSafe({ _name: "SecurityCouncilSafe", _newThreshold: exampleCouncilConfig.safeConfig.threshold }); - address[] memory owners = safe.getOwners(); + // Verify the module was configured correctly + (uint256 configuredPeriod, address configuredFallback) = + LivenessModule2(livenessModule).viewConfiguration(address(safe)); + require( + configuredPeriod == exampleCouncilConfig.livenessModuleConfig.livenessInterval, + "DeployOwnership: configured liveness interval must match expected value" + ); require( - safe.getThreshold() == LivenessModule(livenessModule).getRequiredThreshold(owners.length), - "DeployOwnership: safe threshold must be equal to the LivenessModule's required threshold" + configuredFallback == exampleCouncilConfig.livenessModuleConfig.fallbackOwner, + "DeployOwnership: configured fallback owner must match expected value" ); addr_ = address(safe); diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index ff1506d1c55..560882dfc16 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -12,8 +12,8 @@ import { Test } from "forge-std/Test.sol"; import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; -import { LivenessGuard } from "src/safe/LivenessGuard.sol"; -import { LivenessModule } from "src/safe/LivenessModule.sol"; +import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; +import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; contract DeployOwnershipTest is Test, DeployOwnership { address internal constant SENTINEL_MODULES = address(0x1); @@ -60,35 +60,22 @@ contract DeployOwnershipTest is Test, DeployOwnership { _checkSafeConfig(exampleSecurityCouncilConfig.safeConfig, securityCouncilSafe); - // Guard Checks - address livenessGuard = artifacts.mustGetAddress("LivenessGuard"); - - // The Safe's getGuard method is internal, so we read directly from storage - // https://github.com/safe-global/safe-contracts/blob/v1.4.0/contracts/base/GuardManager.sol#L66-L72 - assertEq(vm.load(address(securityCouncilSafe), GUARD_STORAGE_SLOT), bytes32(uint256(uint160(livenessGuard)))); - - // check that all the owners have a lastLive time in the Guard - address[] memory owners = exampleSecurityCouncilConfig.safeConfig.owners; - for (uint256 i = 0; i < owners.length; i++) { - assertEq(LivenessGuard(livenessGuard).lastLive(owners[i]), block.timestamp); - } - // Module Checks - address livenessModule = artifacts.mustGetAddress("LivenessModule"); + address livenessModule = artifacts.mustGetAddress("LivenessModule2"); (address[] memory modules, address nextModule) = ModuleManager(securityCouncilSafe).getModulesPaginated(SENTINEL_MODULES, 2); assertEq(modules.length, 1); assertEq(modules[0], livenessModule); assertEq(nextModule, SENTINEL_MODULES); // ensures there are no more modules in the list - // LivenessModule checks + // LivenessModule2 checks LivenessModuleConfig memory lmConfig = exampleSecurityCouncilConfig.livenessModuleConfig; - assertEq(address(LivenessModule(livenessModule).livenessGuard()), livenessGuard); - assertEq(LivenessModule(livenessModule).livenessInterval(), lmConfig.livenessInterval); - assertEq(LivenessModule(livenessModule).thresholdPercentage(), lmConfig.thresholdPercentage); - assertEq(LivenessModule(livenessModule).minOwners(), lmConfig.minOwners); + (uint256 configuredPeriod, address configuredFallback) = + LivenessModule2(livenessModule).viewConfiguration(address(securityCouncilSafe)); + assertEq(configuredPeriod, lmConfig.livenessInterval); + assertEq(configuredFallback, lmConfig.fallbackOwner); - // Ensure the threshold on the safe agrees with the LivenessModule's required threshold - assertEq(securityCouncilSafe.getThreshold(), LivenessModule(livenessModule).getRequiredThreshold(owners.length)); + // Verify no active challenge exists initially + assertEq(LivenessModule2(livenessModule).isChallenged(address(securityCouncilSafe)), 0); } } From dd75f3e2bafa0e1ea7d0d2426570dbe86b64ba51 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 06:32:46 +0200 Subject: [PATCH 03/45] cleanup --- .../interfaces/safe/ILivenessModule2.sol | 3 - .../scripts/deploy/DeployOwnership.s.sol | 2 - .../snapshots/abi/LivenessModule2.json | 269 ++++++++++++++++++ .../snapshots/semver-lock.json | 4 + .../storageLayout/LivenessModule2.json | 9 + .../src/safe/LivenessModule2.sol | 3 - .../test/safe/LivenessModule2.t.sol | 9 - .../test/scripts/DeployOwnership.t.sol | 1 - 8 files changed, 282 insertions(+), 18 deletions(-) create mode 100644 packages/contracts-bedrock/snapshots/abi/LivenessModule2.json create mode 100644 packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 4558230f87c..9a26f82ae84 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -10,9 +10,6 @@ interface ILivenessModule2 is ISemver { /// @notice Error for when module is not enabled for the Safe error LivenessModule2_ModuleNotEnabled(); - /// @notice Error for when module is already enabled for the Safe - error LivenessModule2_ModuleAlreadyEnabled(); - /// @notice Error for when a challenge already exists error LivenessModule2_ChallengeAlreadyExists(); diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 3ed0582b674..fab5eec14cb 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -7,13 +7,11 @@ import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { GnosisSafeProxyFactory as SafeProxyFactory } from "safe-contracts/proxies/GnosisSafeProxyFactory.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 { Enum as SafeOps } from "safe-contracts/common/Enum.sol"; import { DeployUtils } from "scripts/libraries/DeployUtils.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; -import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json new file mode 100644 index 00000000000..0488182527a --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -0,0 +1,269 @@ +[ + { + "inputs": [], + "name": "cancelChallenge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "changeOwnershipToFallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "disableModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_livenessChallengePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_fallbackOwner", + "type": "address" + } + ], + "name": "enableModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "isChallenged", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "safeConfigs", + "outputs": [ + { + "internalType": "uint256", + "name": "livenessChallengePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "challengeStartTime", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "startChallenge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "viewConfiguration", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "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": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ChallengeExecuted", + "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" + } + ], + "name": "ModuleDisabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "livenessChallengePeriod", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ModuleEnabled", + "type": "event" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeAlreadyExists", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeNotSuccessful", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidParameters", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_OwnerNotFound", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_UnauthorizedCaller", + "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 24d25b9b066..cc2ea761801 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -199,6 +199,10 @@ "initCodeHash": "0xde3b3273aa37604048b5fa228b90f3b05997db613dfcda45061545a669b2476a", "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, + "src/safe/LivenessModule2.sol:LivenessModule2": { + "initCodeHash": "0x8230d6b7d068273ac8c991e18c5becadabbe719674950dd99466173fbf9c526f", + "sourceCodeHash": "0x75e83521f7821df471e7b1b5c418cca0680d107cbe7bcef465f7937ee881720e" + }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", "sourceCodeHash": "0xf5e29dd5c750ea935c7281ec916ba5277f5610a0a9e984e53ae5d5245b3cf2f4" diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json new file mode 100644 index 00000000000..5b5b0b812c7 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -0,0 +1,9 @@ +[ + { + "bytes": "32", + "label": "safeConfigs", + "offset": 0, + "slot": "0", + "type": "mapping(address => struct LivenessModule2.SafeConfig)" + } +] \ 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 59cef2e5c73..9fd83c4bb1f 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -46,9 +46,6 @@ contract LivenessModule2 is ILivenessModule2 { // Set the caller as a safe and store its configuration SafeConfig storage config = safeConfigs[msg.sender]; - if (config.fallbackOwner != address(0)) { - revert LivenessModule2_ModuleAlreadyEnabled(); - } // Store the parameters related to this safe config.livenessChallengePeriod = _livenessChallengePeriod; diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index ea4df524031..ab7433c573f 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -155,15 +155,6 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { livenessModule2.changeOwnershipToFallback(address(newSafe.safe)); } - function test_enableModule_alreadyEnabled_reverts() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Try to enable again directly from the Safe address - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleAlreadyEnabled.selector); - vm.prank(address(safeInstance.safe)); - livenessModule2.enableModule(CHALLENGE_PERIOD, fallbackOwner); - } - function test_enableModule_invalidParameters_reverts() external { // Test with zero period vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index 560882dfc16..bb265fb15e4 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -13,7 +13,6 @@ import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { ModuleManager } from "safe-contracts/base/ModuleManager.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; -import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; contract DeployOwnershipTest is Test, DeployOwnership { address internal constant SENTINEL_MODULES = address(0x1); From 5f934f7757283c6732d4924cf6590adceb1d6870 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 06:42:59 +0200 Subject: [PATCH 04/45] reset challangeStartTime when re-enabling the module Co-authored-by: almanax-ai[bot] <174396398+almanax-ai[bot]@users.noreply.github.com> --- 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 9fd83c4bb1f..031b156d919 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -47,9 +47,11 @@ contract LivenessModule2 is ILivenessModule2 { // Set the caller as a safe and store its configuration SafeConfig storage config = safeConfigs[msg.sender]; + // Store the parameters related to this safe // Store the parameters related to this safe config.livenessChallengePeriod = _livenessChallengePeriod; config.fallbackOwner = _fallbackOwner; + config.challengeStartTime = 0; emit ModuleEnabled(msg.sender, _livenessChallengePeriod, _fallbackOwner); } From ec5b31ade7530d1f60814eb558597034e256ce92 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 06:43:43 +0200 Subject: [PATCH 05/45] remove duplicated comment --- 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 031b156d919..f69161dac0e 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -47,7 +47,6 @@ contract LivenessModule2 is ILivenessModule2 { // Set the caller as a safe and store its configuration SafeConfig storage config = safeConfigs[msg.sender]; - // Store the parameters related to this safe // Store the parameters related to this safe config.livenessChallengePeriod = _livenessChallengePeriod; config.fallbackOwner = _fallbackOwner; From 59cae4d4825fcfc0689ec91e7115df5ecb4e4f9c Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 07:02:59 +0200 Subject: [PATCH 06/45] test cleanup --- .../test/safe/LivenessModule2.t.sol | 135 +----------------- 1 file changed, 7 insertions(+), 128 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index ab7433c573f..6359fee805f 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -92,7 +92,7 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { assertEq(fbOwner, fallbackOwner); } - function test_enableModule_acceptsArbitraryNumberOfSafes_succeeds() external { + function test_enableModule_multipleSafes_succeeds() external { // Test that multiple independent safes can enable the module address safe1 = makeAddr("safe1"); address safe2 = makeAddr("safe2"); @@ -270,7 +270,7 @@ contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { assertFalse(success, "Should fail to cancel non-existent challenge"); } - function test_cancelChallenge_afterPeriod_reverts() external { + function test_cancelChallenge_afterChallengePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.startChallenge(address(safeInstance.safe)); @@ -339,7 +339,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } - function test_changeOwnershipToFallback_challengeNotSuccessful_reverts() external { + function test_changeOwnershipToFallback_beforeChallengePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.startChallenge(address(safeInstance.safe)); @@ -368,7 +368,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(newOwners[0], fallbackOwner); } - function test_changeOwnershipToFallback_allowsNewChallenge_succeeds() external { + function test_changeOwnershipToFallback_canRechallenge_succeeds() external { // Start and execute first challenge vm.prank(fallbackOwner); livenessModule2.startChallenge(address(safeInstance.safe)); @@ -388,7 +388,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI /// @title LivenessModule2_ViewConfiguration_Test /// @notice Tests view functions contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { - function test_viewConfiguration_works() external { + function test_viewConfiguration_succeeds() external { // Before enabling (uint256 period1, address fbOwner1) = livenessModule2.viewConfiguration(address(safeInstance.safe)); assertEq(period1, 0); @@ -401,7 +401,7 @@ contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { assertEq(fbOwner2, fallbackOwner); } - function test_isChallenged_works() external { + function test_isChallenged_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // No challenge @@ -417,130 +417,9 @@ contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); } - function test_version_works() external view { + function test_version_succeeds() external view { assertTrue(bytes(livenessModule2.version()).length > 0); } } -/// @title LivenessModule2_IsChallenged_Test -/// @notice Tests invariants from specifications -contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { - /// @notice Test iLM-001: No Concurrent Challenges - function test_invariant_noConcurrentChallenges_works() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Start first challenge - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - - // Attempt second challenge should fail - vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - } - - /// @notice Test iLM-002: Honest Users Can Recover From Temporary Key Control - function test_invariant_honestUsersCanRecover_works() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Simulate attacker with control over less than quorum - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - - // Honest users (with quorum) can cancel the challenge - _cancelChallenge(safeInstance); - - // Verify Safe is still controlled by original owners - address[] memory currentOwners = safeInstance.safe.getOwners(); - assertEq(currentOwners.length, NUM_OWNERS); - for (uint256 i = 0; i < NUM_OWNERS; i++) { - bool found = false; - for (uint256 j = 0; j < owners.length; j++) { - if (currentOwners[i] == owners[j]) { - found = true; - break; - } - } - assertTrue(found); - } - } - - /// @notice Test iLM-003: A Quorum Of Honest Users Retains Ownership - function test_invariant_quorumRetainsOwnership_works() external { - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - - // Start a challenge - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - - // Quorum of honest users cancels before period expires - vm.warp(block.timestamp + CHALLENGE_PERIOD - 1); - _cancelChallenge(safeInstance); - - // Verify ownership unchanged - address[] memory currentOwners = safeInstance.safe.getOwners(); - assertEq(currentOwners.length, NUM_OWNERS); - assertEq(safeInstance.safe.getThreshold(), THRESHOLD); - } -} - -/// @title LivenessModule2_CancelChallenge_Test -/// @notice Integration tests with Safe operations -contract LivenessModule2_CancelChallenge_Test is LivenessModule2_TestInit { - function test_integration_fullChallengeFlow_works() external { - // Enable module - _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - // Start challenge - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - - // Safe responds and cancels challenge - _cancelChallenge(safeInstance); - - // Start another challenge - vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); - - // This time Safe doesn't respond - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // Execute ownership transfer - livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); - - // Verify fallback owner has control - address[] memory newOwners = safeInstance.safe.getOwners(); - assertEq(newOwners.length, 1); - assertEq(newOwners[0], fallbackOwner); - assertEq(safeInstance.safe.getThreshold(), 1); - } - - function test_integration_multipleEnable_works() external { - // Use different key sets for different safes - (, uint256[] memory keys1) = SafeTestLib.makeAddrsAndKeys("safe1", NUM_OWNERS); - (, uint256[] memory keys2) = SafeTestLib.makeAddrsAndKeys("safe2", NUM_OWNERS); - - SafeInstance memory safe1 = _setupSafe(keys1, THRESHOLD); - SafeInstance memory safe2 = _setupSafe(keys2, THRESHOLD); - - SafeTestLib.enableModule(safe1, address(livenessModule2)); - SafeTestLib.enableModule(safe2, address(livenessModule2)); - - address fallback1 = makeAddr("fallback1"); - address fallback2 = makeAddr("fallback2"); - - // Enable module on both safes - _enableModule(safe1, CHALLENGE_PERIOD, fallback1); - _enableModule(safe2, CHALLENGE_PERIOD * 2, fallback2); - - // Verify configurations are independent - (uint256 period1, address fb1) = livenessModule2.viewConfiguration(address(safe1.safe)); - (uint256 period2, address fb2) = livenessModule2.viewConfiguration(address(safe2.safe)); - - assertEq(period1, CHALLENGE_PERIOD); - assertEq(fb1, fallback1); - assertEq(period2, CHALLENGE_PERIOD * 2); - assertEq(fb2, fallback2); - } -} From e93c34fcb0ea6575c38f2798934e1623eec79fc6 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 07:06:53 +0200 Subject: [PATCH 07/45] fmt --- packages/contracts-bedrock/test/safe/LivenessModule2.t.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 6359fee805f..1dde559a4e2 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -421,5 +421,3 @@ contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { assertTrue(bytes(livenessModule2.version()).length > 0); } } - - From ee88fb3b64de164a0081e46febbc10ad7106f719 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 07:58:45 +0200 Subject: [PATCH 08/45] semverlock --- 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 cc2ea761801..6c6cbedb1bb 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x8230d6b7d068273ac8c991e18c5becadabbe719674950dd99466173fbf9c526f", - "sourceCodeHash": "0x75e83521f7821df471e7b1b5c418cca0680d107cbe7bcef465f7937ee881720e" + "initCodeHash": "0x6cca709ca7dcc5facf4ad9cd428435a885e4da0892efa90f0a01f3da014e84da", + "sourceCodeHash": "0x0148d2df1bfa7f9fe5cb371873482328ba5fc1477c3da6b37a0e603050916505" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", From dc1887d4f602af76741a2b74391db5708bd217ee Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 08:10:38 +0200 Subject: [PATCH 09/45] add livenessmodule2 to checkfrozenfiles --- packages/contracts-bedrock/scripts/checks/check-frozen-files.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh b/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh index d509329ee90..50522e9568f 100755 --- a/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh +++ b/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh @@ -98,6 +98,7 @@ ALLOWED_FILES=( "src/safe/DeputyPauseModule.sol:DeputyPauseModule" "src/safe/LivenessGuard.sol:LivenessGuard" "src/safe/LivenessModule.sol:LivenessModule" + "src/safe/LivenessModule2.sol:LivenessModule2" "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20" "src/universal/OptimismMintableERC20Factory.sol:OptimismMintableERC20Factory" "src/universal/StorageSetter.sol:StorageSetter" From 989ae745c482be07f1377bcaa220a51fc70b4378 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 09:49:00 +0200 Subject: [PATCH 10/45] address alcueca's feedback --- .../interfaces/safe/ILivenessModule2.sol | 23 +++-- .../snapshots/abi/LivenessModule2.json | 19 ++-- .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 94 ++++++++----------- .../test/safe/LivenessModule2.t.sol | 12 +-- 5 files changed, 71 insertions(+), 81 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 9a26f82ae84..06cfc25aca5 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -16,8 +16,11 @@ interface ILivenessModule2 is ISemver { /// @notice Error for when no challenge exists error LivenessModule2_ChallengeDoesNotExist(); - /// @notice Error for when challenge is not successful - error LivenessModule2_ChallengeNotSuccessful(); + /// @notice Error for when trying to cancel a challenge after the response period has expired + error LivenessModule2_ResponsePeriodExpired(); + + /// @notice Error for when trying to execute ownership transfer while the response period is still active + error LivenessModule2_ResponsePeriodActive(); /// @notice Error for when caller is not authorized error LivenessModule2_UnauthorizedCaller(); @@ -29,7 +32,7 @@ interface ILivenessModule2 is ISemver { error LivenessModule2_OwnerNotFound(); /// @notice Emitted when a Safe enables the module - event ModuleEnabled(address indexed safe, uint256 livenessChallengePeriod, address fallbackOwner); + event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); /// @notice Emitted when a Safe disables the module event ModuleDisabled(address indexed safe); @@ -44,30 +47,30 @@ interface ILivenessModule2 is ISemver { event ChallengeExecuted(address indexed safe, address fallbackOwner); /// @notice Returns the configuration for a Safe - /// @return livenessChallengePeriod The challenge period + /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address /// @return challengeStartTime The challenge start time (0 if no challenge) - function safeConfigs(address) external view returns (uint256 livenessChallengePeriod, address fallbackOwner, uint256 challengeStartTime); + function safeConfigs(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner, uint256 challengeStartTime); /// @notice Semantic version /// @return version The contract version function version() external view returns (string memory); /// @notice Enables the module by the multisig to be challenged - /// @param _livenessChallengePeriod The period in seconds for a liveness challenge + /// @param _livenessResponsePeriod The period in seconds for a liveness response /// @param _fallbackOwner The address that will become owner if challenge succeeds - function enableModule(uint256 _livenessChallengePeriod, address _fallbackOwner) external; + function enableModule(uint256 _livenessResponsePeriod, address _fallbackOwner) external; /// @notice Disables the module by an enabled safe function disableModule() external; - /// @notice Returns the liveness_challenge_period and fallback_owner for a given safe + /// @notice Returns the liveness_response_period and fallback_owner for a given safe /// @param _safe The Safe address to query - /// @return livenessChallengePeriod The challenge period + /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address function viewConfiguration(address _safe) external view returns (uint256, address); - /// @notice Returns challenge_start_time + liveness_challenge_period if there is a challenge, or 0 if not + /// @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 isChallenged(address _safe) external view returns (uint256); diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 0488182527a..521a2632100 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -30,7 +30,7 @@ "inputs": [ { "internalType": "uint256", - "name": "_livenessChallengePeriod", + "name": "_livenessResponsePeriod", "type": "uint256" }, { @@ -75,7 +75,7 @@ "outputs": [ { "internalType": "uint256", - "name": "livenessChallengePeriod", + "name": "livenessResponsePeriod", "type": "uint256" }, { @@ -218,7 +218,7 @@ { "indexed": false, "internalType": "uint256", - "name": "livenessChallengePeriod", + "name": "livenessResponsePeriod", "type": "uint256" }, { @@ -243,22 +243,27 @@ }, { "inputs": [], - "name": "LivenessModule2_ChallengeNotSuccessful", + "name": "LivenessModule2_InvalidParameters", "type": "error" }, { "inputs": [], - "name": "LivenessModule2_InvalidParameters", + "name": "LivenessModule2_ModuleNotEnabled", "type": "error" }, { "inputs": [], - "name": "LivenessModule2_ModuleNotEnabled", + "name": "LivenessModule2_OwnerNotFound", "type": "error" }, { "inputs": [], - "name": "LivenessModule2_OwnerNotFound", + "name": "LivenessModule2_ResponsePeriodActive", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ResponsePeriodExpired", "type": "error" }, { diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 6c6cbedb1bb..9c707a47cad 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x6cca709ca7dcc5facf4ad9cd428435a885e4da0892efa90f0a01f3da014e84da", - "sourceCodeHash": "0x0148d2df1bfa7f9fe5cb371873482328ba5fc1477c3da6b37a0e603050916505" + "initCodeHash": "0x54cee44e481bd81d299bbb84c9017ba957fc287325e49dfaaab61d921e9bf091", + "sourceCodeHash": "0x426d838cb14fcdc2e1a7b5e4787eb090852af0a546be2b228a609b3ae8b932aa" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index f69161dac0e..dd6c2e18fde 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -20,7 +20,7 @@ import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; contract LivenessModule2 is ILivenessModule2 { /// @notice Configuration for a Safe's liveness module struct SafeConfig { - uint256 livenessChallengePeriod; + uint256 livenessResponsePeriod; address fallbackOwner; uint256 challengeStartTime; } @@ -32,15 +32,15 @@ contract LivenessModule2 is ILivenessModule2 { /// @custom:semver 2.0.0 string public constant version = "2.0.0"; - /// @notice Enables the module by the multisig to be challenged and sets the liveness_challenge_period and + /// @notice Enables the module by the multisig to be challenged and sets the liveness_response_period and /// fallback_owner /// @dev MUST set the caller as a safe - /// @dev MUST take as parameters liveness_challenge_period and fallback_owner and store them as related to the safe + /// @dev MUST take as parameters liveness_response_period and fallback_owner and store them as related to the safe /// @dev MUST accept an arbitrary number of independent safe contracts to enable the module - /// @param _livenessChallengePeriod The period in seconds for a liveness challenge + /// @param _livenessResponsePeriod The period in seconds for a liveness response /// @param _fallbackOwner The address that will become owner if challenge succeeds - function enableModule(uint256 _livenessChallengePeriod, address _fallbackOwner) external { - if (_livenessChallengePeriod == 0 || _fallbackOwner == address(0)) { + function enableModule(uint256 _livenessResponsePeriod, address _fallbackOwner) external { + if (_livenessResponsePeriod == 0 || _fallbackOwner == address(0)) { revert LivenessModule2_InvalidParameters(); } @@ -48,16 +48,17 @@ contract LivenessModule2 is ILivenessModule2 { SafeConfig storage config = safeConfigs[msg.sender]; // Store the parameters related to this safe - config.livenessChallengePeriod = _livenessChallengePeriod; + config.livenessResponsePeriod = _livenessResponsePeriod; config.fallbackOwner = _fallbackOwner; config.challengeStartTime = 0; - emit ModuleEnabled(msg.sender, _livenessChallengePeriod, _fallbackOwner); + emit ModuleEnabled(msg.sender, _livenessResponsePeriod, _fallbackOwner); } /// @notice Disables the module by an enabled safe /// @dev MUST only be executable by an enabled safe - /// @dev MUST erase the existing liveness_challenge_period and fallback_owner data related to the calling safe + /// @dev MUST erase the existing liveness_response_period and fallback_owner data related to the calling safe + /// @dev Note: Disabling the module also cancels any ongoing challenges function disableModule() external { SafeConfig storage config = safeConfigs[msg.sender]; // Check if the calling safe has the module enabled @@ -70,17 +71,17 @@ contract LivenessModule2 is ILivenessModule2 { emit ModuleDisabled(msg.sender); } - /// @notice Returns the liveness_challenge_period and fallback_owner for a given safe + /// @notice Returns the liveness_response_period and fallback_owner for a given safe /// @dev MUST never revert /// @param _safe The Safe address to query - /// @return livenessChallengePeriod The challenge period + /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address function viewConfiguration(address _safe) external view returns (uint256, address) { SafeConfig storage config = safeConfigs[_safe]; - return (config.livenessChallengePeriod, config.fallbackOwner); + return (config.livenessResponsePeriod, config.fallbackOwner); } - /// @notice Returns challenge_start_time + liveness_challenge_period if there is a challenge for the given safe, or + /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge for the given safe, or /// 0 if not /// @dev MUST never revert /// @param _safe The Safe address to query @@ -90,7 +91,7 @@ contract LivenessModule2 is ILivenessModule2 { if (config.challengeStartTime == 0) { return 0; } - return config.challengeStartTime + config.livenessChallengePeriod; + return config.challengeStartTime + config.livenessResponsePeriod; } /// @notice Challenges an enabled safe @@ -134,9 +135,9 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_ChallengeDoesNotExist(); } - // Check if challenge period has expired - if (block.timestamp >= config.challengeStartTime + config.livenessChallengePeriod) { - revert LivenessModule2_ChallengeNotSuccessful(); + // Check if response period has expired + if (block.timestamp >= config.challengeStartTime + config.livenessResponsePeriod) { + revert LivenessModule2_ResponsePeriodExpired(); } config.challengeStartTime = 0; @@ -162,9 +163,9 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_ChallengeDoesNotExist(); } - // Check if challenge period has expired - if (block.timestamp < config.challengeStartTime + config.livenessChallengePeriod) { - revert LivenessModule2_ChallengeNotSuccessful(); + // Check if response period has expired + if (block.timestamp < config.challengeStartTime + config.livenessResponsePeriod) { + revert LivenessModule2_ResponsePeriodActive(); } Safe safe = Safe(payable(_safe)); @@ -172,53 +173,34 @@ contract LivenessModule2 is ILivenessModule2 { // Get current owners address[] memory currentOwners = safe.getOwners(); - // Remove all current owners and add fallback owner - // We need to do this by swapping the first owner with the fallback owner, - // then removing the remaining owners if (currentOwners.length > 0) { - // First, swap the first owner with the fallback owner - address prevOwner = address(0x1); // Sentinel value for the first owner in the Safe's linked list + // Remove all owners except the first (in reverse order to maintain linked list integrity) + for (uint256 i = currentOwners.length; i > 1; i--) { + address ownerToRemove = currentOwners[i - 1]; + address prev = _findPrevOwner(safe, ownerToRemove); + safe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.removeOwner, (prev, ownerToRemove, 1)) + }); + } + + // Set threshold to 1 safe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.swapOwner, (prevOwner, currentOwners[0], config.fallbackOwner)) + data: abi.encodeCall(OwnerManager.changeThreshold, (1)) }); - // Then remove all other owners if there are any - for (uint256 i = 1; i < currentOwners.length; i++) { - // After swapping, the fallback owner is now in the list - // We need to be careful about the linked list structure - address[] memory updatedOwners = safe.getOwners(); - address ownerToRemove = currentOwners[i]; - - // Find if this owner is still in the list (it should be unless already removed) - bool found = false; - for (uint256 j = 0; j < updatedOwners.length; j++) { - if (updatedOwners[j] == ownerToRemove) { - found = true; - break; - } - } - - if (found) { - // Find the previous owner in the linked list - address prev = _findPrevOwner(safe, ownerToRemove); - safe.execTransactionFromModule({ - to: _safe, - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.removeOwner, (prev, ownerToRemove, 1)) - }); - } - } - - // Set threshold to 1 + // Swap the remaining owner with the fallback owner + address prevOwner = address(0x1); // Sentinel value for the first owner in the Safe's linked list safe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.changeThreshold, (1)) + data: abi.encodeCall(OwnerManager.swapOwner, (prevOwner, currentOwners[0], config.fallbackOwner)) }); } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 1dde559a4e2..aeee58ddec8 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -270,7 +270,7 @@ contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { assertFalse(success, "Should fail to cancel non-existent challenge"); } - function test_cancelChallenge_afterChallengePeriod_reverts() external { + function test_cancelChallenge_afterResponsePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.startChallenge(address(safeInstance.safe)); @@ -278,8 +278,8 @@ contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - // Try to cancel - should fail as challenge is successful - vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeNotSuccessful.selector); + // Try to cancel - should fail as response period has expired + vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodExpired.selector); vm.prank(address(safeInstance.safe)); livenessModule2.cancelChallenge(); } @@ -339,13 +339,13 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } - function test_changeOwnershipToFallback_beforeChallengePeriod_reverts() external { + function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.startChallenge(address(safeInstance.safe)); - // Try to execute before period expires - vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeNotSuccessful.selector); + // Try to execute before response period expires + vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodActive.selector); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } From d3bf748577070e57288ffff77aee4fb0a3dbc495 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 12:34:38 +0200 Subject: [PATCH 11/45] change ownershiptransfer logic --- .../interfaces/safe/ILivenessModule2.sol | 2 - .../src/safe/LivenessModule2.sol | 47 +++++-------------- 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 06cfc25aca5..0a76eaf6ce2 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -28,8 +28,6 @@ interface ILivenessModule2 is ISemver { /// @notice Error for invalid parameters error LivenessModule2_InvalidParameters(); - /// @notice Error for when an owner is not found in the Safe's owner list - error LivenessModule2_OwnerNotFound(); /// @notice Emitted when a Safe enables the module event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index dd6c2e18fde..ef8f117c48f 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -174,33 +174,28 @@ contract LivenessModule2 is ILivenessModule2 { address[] memory currentOwners = safe.getOwners(); if (currentOwners.length > 0) { - // Remove all owners except the first (in reverse order to maintain linked list integrity) - for (uint256 i = currentOwners.length; i > 1; i--) { - address ownerToRemove = currentOwners[i - 1]; - address prev = _findPrevOwner(safe, ownerToRemove); + // Remove all owners except the first one from the front + // removeOwner automatically updates the threshold, so we don't need to do it manually + address sentinel = address(0x1); // Sentinel value for the first owner + + for (uint256 i = currentOwners.length - 1; i > 0; i--) { + address ownerToRemove = currentOwners[0]; // Always remove the first owner safe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.removeOwner, (prev, ownerToRemove, 1)) + data: abi.encodeCall(OwnerManager.removeOwner, (sentinel, ownerToRemove, 1)) }); + // Get updated owners list after removal + currentOwners = safe.getOwners(); } - // Set threshold to 1 + // Now swap the remaining single owner with the fallback owner safe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.changeThreshold, (1)) - }); - - // Swap the remaining owner with the fallback owner - address prevOwner = address(0x1); // Sentinel value for the first owner in the Safe's linked list - safe.execTransactionFromModule({ - to: _safe, - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.swapOwner, (prevOwner, currentOwners[0], config.fallbackOwner)) + data: abi.encodeCall(OwnerManager.swapOwner, (sentinel, currentOwners[0], config.fallbackOwner)) }); } @@ -210,24 +205,4 @@ contract LivenessModule2 is ILivenessModule2 { emit ChallengeExecuted(_safe, config.fallbackOwner); } - /// @notice Helper function to find the previous owner in the Safe's linked list - /// @param _safe The Safe contract - /// @param _owner The owner to find the previous of - /// @return The previous owner address - function _findPrevOwner(Safe _safe, address _owner) private view returns (address) { - address[] memory owners = _safe.getOwners(); - - // Sentinel address is used as the previous for the first owner - address prev = address(0x1); - - for (uint256 i = 0; i < owners.length; i++) { - if (owners[i] == _owner) { - return prev; - } - prev = owners[i]; - } - - // This should not happen if the owner exists - revert LivenessModule2_OwnerNotFound(); - } } From 34419fe0d6581f8a159b4c330b6ca6fd05c58d68 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 12:50:08 +0200 Subject: [PATCH 12/45] pre-pr --- .../contracts-bedrock/snapshots/abi/LivenessModule2.json | 5 ----- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 3 +-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 521a2632100..8e85c2e8c07 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -251,11 +251,6 @@ "name": "LivenessModule2_ModuleNotEnabled", "type": "error" }, - { - "inputs": [], - "name": "LivenessModule2_OwnerNotFound", - "type": "error" - }, { "inputs": [], "name": "LivenessModule2_ResponsePeriodActive", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 9c707a47cad..d5c154f660e 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x54cee44e481bd81d299bbb84c9017ba957fc287325e49dfaaab61d921e9bf091", - "sourceCodeHash": "0x426d838cb14fcdc2e1a7b5e4787eb090852af0a546be2b228a609b3ae8b932aa" + "initCodeHash": "0xc1023ddb6de3dcbfb9807a1910eb22561f07cb7b658ee9f489a3dbe8b1eb8027", + "sourceCodeHash": "0xb9d375981428347d26d8984cbea0381d09584f62f470681c75f107b050cf83c8" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index ef8f117c48f..0ce0590de10 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -177,7 +177,7 @@ contract LivenessModule2 is ILivenessModule2 { // Remove all owners except the first one from the front // removeOwner automatically updates the threshold, so we don't need to do it manually address sentinel = address(0x1); // Sentinel value for the first owner - + for (uint256 i = currentOwners.length - 1; i > 0; i--) { address ownerToRemove = currentOwners[0]; // Always remove the first owner safe.execTransactionFromModule({ @@ -204,5 +204,4 @@ contract LivenessModule2 is ILivenessModule2 { emit ChallengeExecuted(_safe, config.fallbackOwner); } - } From 548fcf4a86261da32d19864c51c584043f9fb7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Cuesta=20Ca=C3=B1ada?= <38806121+alcueca@users.noreply.github.com> Date: Fri, 29 Aug 2025 18:42:09 +0100 Subject: [PATCH 13/45] LivenessModule - Slightly simplified code (#17277) * Slightly simplified code --------- Co-authored-by: alcueca --- .../src/safe/LivenessModule2.sol | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 0ce0590de10..9a8b256cdfe 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -18,6 +18,10 @@ import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; /// 1. The Safe must first enable this module using ModuleManager.enableModule() /// 2. The Safe must then configure the module by calling enableModule() with parameters contract LivenessModule2 is ILivenessModule2 { + + /// @notice Reserved address used as the previous owner to the first owner in a Safe + address public constant SENTINEL_OWNER = address(0x1); + /// @notice Configuration for a Safe's liveness module struct SafeConfig { uint256 livenessResponsePeriod; @@ -171,34 +175,29 @@ contract LivenessModule2 is ILivenessModule2 { Safe safe = Safe(payable(_safe)); // Get current owners - address[] memory currentOwners = safe.getOwners(); + address[] memory owners = safe.getOwners(); - if (currentOwners.length > 0) { - // Remove all owners except the first one from the front + // Remove all owners after the first one + while (owners.length > 1) { // removeOwner automatically updates the threshold, so we don't need to do it manually - address sentinel = address(0x1); // Sentinel value for the first owner - - for (uint256 i = currentOwners.length - 1; i > 0; i--) { - address ownerToRemove = currentOwners[0]; // Always remove the first owner - safe.execTransactionFromModule({ - to: _safe, - value: 0, - operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.removeOwner, (sentinel, ownerToRemove, 1)) - }); - // Get updated owners list after removal - currentOwners = safe.getOwners(); - } - - // Now swap the remaining single owner with the fallback owner safe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.swapOwner, (sentinel, currentOwners[0], config.fallbackOwner)) + data: abi.encodeCall(OwnerManager.removeOwner, (SENTINEL_OWNER, owners[0], 1)) }); + // Get updated owners list after removal + owners = safe.getOwners(); } + // Now swap the remaining single owner with the fallback owner + safe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], config.fallbackOwner)) + }); + // Reset the challenge state to allow a new challenge config.challengeStartTime = 0; From 874836db33f437dead31e80151f72fe9ae4cafac Mon Sep 17 00:00:00 2001 From: JosepBove Date: Fri, 29 Aug 2025 21:53:52 +0200 Subject: [PATCH 14/45] address smartcontract's feedback --- .../interfaces/safe/ILivenessModule2.sol | 24 ++--- .../scripts/deploy/DeployOwnership.s.sol | 2 +- .../snapshots/abi/LivenessModule2.json | 91 ++++++++--------- .../snapshots/semver-lock.json | 4 +- .../storageLayout/LivenessModule2.json | 9 +- .../src/safe/LivenessModule2.sol | 70 +++++++------ .../test/safe/LivenessModule2.t.sol | 99 ++++++++++--------- .../test/scripts/DeployOwnership.t.sol | 2 +- 8 files changed, 157 insertions(+), 144 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 0a76eaf6ce2..247cf64ef41 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -44,11 +44,18 @@ interface ILivenessModule2 is ISemver { /// @notice Emitted when ownership is transferred to the fallback owner event ChallengeExecuted(address indexed safe, address fallbackOwner); + /// @notice Reserved address used as the previous owner to the first owner in a Safe + /// @return The sentinel owner address (0x1) + function SENTINEL_OWNER() external view returns (address); + /// @notice Returns the configuration for a Safe /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address - /// @return challengeStartTime The challenge start time (0 if no challenge) - function safeConfigs(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner, uint256 challengeStartTime); + function safeConfigs(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 @@ -60,13 +67,8 @@ interface ILivenessModule2 is ISemver { function enableModule(uint256 _livenessResponsePeriod, address _fallbackOwner) external; /// @notice Disables the module by an enabled safe - function disableModule() external; + function disable() external; - /// @notice Returns the liveness_response_period and fallback_owner for a given safe - /// @param _safe The Safe address to query - /// @return livenessResponsePeriod The response period - /// @return fallbackOwner The fallback owner address - function viewConfiguration(address _safe) external view returns (uint256, address); /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge, or 0 if not /// @param _safe The Safe address to query @@ -75,10 +77,10 @@ interface ILivenessModule2 is ISemver { /// @notice Challenges an enabled safe /// @param _safe The Safe to challenge - function startChallenge(address _safe) external; + function challenge(address _safe) external; - /// @notice Cancels a challenge for an enabled safe - function cancelChallenge() 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 diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index fab5eec14cb..b934f0d2869 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -318,7 +318,7 @@ contract DeployOwnership is Deploy { // Verify the module was configured correctly (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).viewConfiguration(address(safe)); + LivenessModule2(livenessModule).safeConfigs(address(safe)); require( configuredPeriod == exampleCouncilConfig.livenessModuleConfig.livenessInterval, "DeployOwnership: configured liveness interval must match expected value" diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 8e85c2e8c07..76f8f803769 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -1,11 +1,49 @@ [ { "inputs": [], - "name": "cancelChallenge", + "name": "SENTINEL_OWNER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "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": [ { @@ -21,7 +59,7 @@ }, { "inputs": [], - "name": "disableModule", + "name": "disable", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -63,6 +101,13 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "respond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -82,29 +127,11 @@ "internalType": "address", "name": "fallbackOwner", "type": "address" - }, - { - "internalType": "uint256", - "name": "challengeStartTime", - "type": "uint256" } ], "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_safe", - "type": "address" - } - ], - "name": "startChallenge", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [], "name": "version", @@ -118,30 +145,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "_safe", - "type": "address" - } - ], - "name": "viewConfiguration", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - }, - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "anonymous": false, "inputs": [ diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index d5c154f660e..5f61a0017d1 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0xc1023ddb6de3dcbfb9807a1910eb22561f07cb7b658ee9f489a3dbe8b1eb8027", - "sourceCodeHash": "0xb9d375981428347d26d8984cbea0381d09584f62f470681c75f107b050cf83c8" + "initCodeHash": "0xc99a1f55949d4f5170e9e6cd3ab5c052efe1beb61b6c8e1458cebd091e705f7e", + "sourceCodeHash": "0xed873a13be41ae8093836cc00fd4b0cf9467e6e5876485ec51810ea4dfa71935" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json index 5b5b0b812c7..a9f1811395c 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -4,6 +4,13 @@ "label": "safeConfigs", "offset": 0, "slot": "0", - "type": "mapping(address => struct LivenessModule2.SafeConfig)" + "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/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 9a8b256cdfe..7ee19e55505 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -18,19 +18,20 @@ import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; /// 1. The Safe must first enable this module using ModuleManager.enableModule() /// 2. The Safe must then configure the module by calling enableModule() with parameters contract LivenessModule2 is ILivenessModule2 { - /// @notice Reserved address used as the previous owner to the first owner in a Safe address public constant SENTINEL_OWNER = address(0x1); /// @notice Configuration for a Safe's liveness module - struct SafeConfig { + struct ModuleConfig { uint256 livenessResponsePeriod; address fallbackOwner; - uint256 challengeStartTime; } /// @notice Mapping from Safe address to its configuration - mapping(address => SafeConfig) public safeConfigs; + mapping(address => ModuleConfig) public safeConfigs; + + /// @notice Mapping from Safe address to active challenge start time (0 if no challenge) + mapping(address => uint256) public challengeStartTime; /// @notice Semantic version. /// @custom:semver 2.0.0 @@ -49,12 +50,14 @@ contract LivenessModule2 is ILivenessModule2 { } // Set the caller as a safe and store its configuration - SafeConfig storage config = safeConfigs[msg.sender]; + ModuleConfig storage config = safeConfigs[msg.sender]; // Store the parameters related to this safe config.livenessResponsePeriod = _livenessResponsePeriod; config.fallbackOwner = _fallbackOwner; - config.challengeStartTime = 0; + + // Clear any existing challenge when enabling/re-enabling + delete challengeStartTime[msg.sender]; emit ModuleEnabled(msg.sender, _livenessResponsePeriod, _fallbackOwner); } @@ -63,8 +66,8 @@ contract LivenessModule2 is ILivenessModule2 { /// @dev MUST only be executable by an enabled safe /// @dev MUST erase the existing liveness_response_period and fallback_owner data related to the calling safe /// @dev Note: Disabling the module also cancels any ongoing challenges - function disableModule() external { - SafeConfig storage config = safeConfigs[msg.sender]; + function disable() external { + ModuleConfig storage config = safeConfigs[msg.sender]; // Check if the calling safe has the module enabled if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); @@ -72,30 +75,23 @@ contract LivenessModule2 is ILivenessModule2 { // Erase the configuration data for this safe delete safeConfigs[msg.sender]; + // Also clear any active challenge + delete challengeStartTime[msg.sender]; emit ModuleDisabled(msg.sender); } - /// @notice Returns the liveness_response_period and fallback_owner for a given safe - /// @dev MUST never revert - /// @param _safe The Safe address to query - /// @return livenessResponsePeriod The response period - /// @return fallbackOwner The fallback owner address - function viewConfiguration(address _safe) external view returns (uint256, address) { - SafeConfig storage config = safeConfigs[_safe]; - return (config.livenessResponsePeriod, config.fallbackOwner); - } - /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge for the given safe, or /// 0 if not /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The challenge end timestamp, or 0 if no challenge function isChallenged(address _safe) external view returns (uint256) { - SafeConfig storage config = safeConfigs[_safe]; - if (config.challengeStartTime == 0) { + uint256 startTime = challengeStartTime[_safe]; + if (startTime == 0) { return 0; } - return config.challengeStartTime + config.livenessResponsePeriod; + ModuleConfig storage config = safeConfigs[_safe]; + return startTime + config.livenessResponsePeriod; } /// @notice Challenges an enabled safe @@ -104,8 +100,8 @@ contract LivenessModule2 is ILivenessModule2 { /// @dev MUST set challenge_start_time to the current block time /// @dev MUST emit the ChallengeStarted event /// @param _safe The Safe to challenge - function startChallenge(address _safe) external { - SafeConfig storage config = safeConfigs[_safe]; + function challenge(address _safe) external { + ModuleConfig storage config = safeConfigs[_safe]; if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); @@ -115,36 +111,37 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_UnauthorizedCaller(); } - if (config.challengeStartTime != 0) { + if (challengeStartTime[_safe] != 0) { revert LivenessModule2_ChallengeAlreadyExists(); } - config.challengeStartTime = block.timestamp; + challengeStartTime[_safe] = block.timestamp; emit ChallengeStarted(_safe, block.timestamp); } - /// @notice Cancels a challenge for an enabled safe + /// @notice Responds to a challenge for an enabled safe, canceling it /// @dev MUST only be executable by an enabled safe /// @dev MUST revert if there isn't a challenge for the calling safe - /// @dev MUST revert if there is a challenge for the calling safe but the challenge is successful + /// @dev MUST revert if there is a challenge for the calling safe but the response period has expired /// @dev MUST emit the ChallengeCancelled event - function cancelChallenge() external { - SafeConfig storage config = safeConfigs[msg.sender]; + function respond() external { + ModuleConfig storage config = safeConfigs[msg.sender]; if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); } - if (config.challengeStartTime == 0) { + uint256 startTime = challengeStartTime[msg.sender]; + if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); } // Check if response period has expired - if (block.timestamp >= config.challengeStartTime + config.livenessResponsePeriod) { + if (block.timestamp >= startTime + config.livenessResponsePeriod) { revert LivenessModule2_ResponsePeriodExpired(); } - config.challengeStartTime = 0; + delete challengeStartTime[msg.sender]; emit ChallengeCancelled(msg.sender); } @@ -157,18 +154,19 @@ contract LivenessModule2 is ILivenessModule2 { /// @dev MUST emit the ChallengeExecuted event /// @param _safe The Safe to transfer ownership of function changeOwnershipToFallback(address _safe) external { - SafeConfig storage config = safeConfigs[_safe]; + ModuleConfig storage config = safeConfigs[_safe]; if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); } - if (config.challengeStartTime == 0) { + uint256 startTime = challengeStartTime[_safe]; + if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); } // Check if response period has expired - if (block.timestamp < config.challengeStartTime + config.livenessResponsePeriod) { + if (block.timestamp < startTime + config.livenessResponsePeriod) { revert LivenessModule2_ResponsePeriodActive(); } @@ -199,7 +197,7 @@ contract LivenessModule2 is ILivenessModule2 { }); // Reset the challenge state to allow a new challenge - config.challengeStartTime = 0; + delete challengeStartTime[_safe]; emit ChallengeExecuted(_safe, config.fallbackOwner); } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index aeee58ddec8..f8dfafcb188 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -66,14 +66,14 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @notice Helper to disable the LivenessModule2 for a Safe function _disableModule(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.disableModule, ()), Enum.Operation.Call + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.disable, ()), Enum.Operation.Call ); } - /// @notice Helper to cancel a challenge from a Safe - function _cancelChallenge(SafeInstance memory _safe) internal { + /// @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.cancelChallenge, ()), Enum.Operation.Call + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call ); } } @@ -87,9 +87,10 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period, address fbOwner) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); assertEq(period, CHALLENGE_PERIOD); assertEq(fbOwner, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } function test_enableModule_multipleSafes_succeeds() external { @@ -115,15 +116,15 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { livenessModule2.enableModule(3 days, fallback3); // Verify each safe has independent configuration - (uint256 period1, address fb1) = livenessModule2.viewConfiguration(safe1); + (uint256 period1, address fb1) = livenessModule2.safeConfigs(safe1); assertEq(period1, 1 days); assertEq(fb1, fallback1); - (uint256 period2, address fb2) = livenessModule2.viewConfiguration(safe2); + (uint256 period2, address fb2) = livenessModule2.safeConfigs(safe2); assertEq(period2, 2 days); assertEq(fb2, fallback2); - (uint256 period3, address fb3) = livenessModule2.viewConfiguration(safe3); + (uint256 period3, address fb3) = livenessModule2.safeConfigs(safe3); assertEq(period3, 3 days); assertEq(fb3, fallback3); } @@ -138,14 +139,14 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { _enableModule(newSafe, CHALLENGE_PERIOD, fallbackOwner); // Verify configuration was stored - (uint256 period, address fb) = livenessModule2.viewConfiguration(address(newSafe.safe)); + (uint256 period, address fb) = livenessModule2.safeConfigs(address(newSafe.safe)); assertEq(period, CHALLENGE_PERIOD); assertEq(fb, fallbackOwner); // But trying to execute a challenge should fail because the Safe hasn't // enabled this module at the Safe level (so execTransactionFromModule will fail) vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(newSafe.safe)); + livenessModule2.challenge(address(newSafe.safe)); vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -167,7 +168,7 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { livenessModule2.enableModule(CHALLENGE_PERIOD, address(0)); } - function test_disableModule_succeeds() external { + function test_disable_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); vm.expectEmit(true, true, true, true); @@ -175,85 +176,85 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { _disableModule(safeInstance); - (uint256 period, address fbOwner) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); assertEq(period, 0); assertEq(fbOwner, address(0)); } - function test_disableModule_notEnabled_reverts() external { + function test_disable_notEnabled_reverts() external { vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.disableModule(); + livenessModule2.disable(); } } -/// @title LivenessModule2_StartChallenge_Test +/// @title LivenessModule2_Challenge_Test /// @notice Tests the challenge mechanism -contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { +contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function setUp() public override { super.setUp(); _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); } - function test_startChallenge_succeeds() external { + function test_challenge_succeeds() external { vm.expectEmit(true, true, true, true); emit ChallengeStarted(address(safeInstance.safe), block.timestamp); vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); assertEq(challengeEndTime, block.timestamp + CHALLENGE_PERIOD); } - function test_startChallenge_notFallbackOwner_reverts() external { + function test_challenge_notFallbackOwner_reverts() external { address notFallback = makeAddr("notFallback"); vm.expectRevert(ILivenessModule2.LivenessModule2_UnauthorizedCaller.selector); vm.prank(notFallback); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); } - function test_startChallenge_moduleNotEnabled_reverts() external { + function test_challenge_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(fallbackOwner); - livenessModule2.startChallenge(newSafe); + livenessModule2.challenge(newSafe); } - function test_startChallenge_alreadyExists_reverts() external { + function test_challenge_alreadyExists_reverts() external { vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); } - function test_cancelChallenge_succeeds() external { + function test_respond_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); // Cancel it vm.expectEmit(true, true, true, true); emit ChallengeCancelled(address(safeInstance.safe)); - _cancelChallenge(safeInstance); + _respondToChallenge(safeInstance); // Verify challenge is cancelled uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); assertEq(challengeEndTime, 0); } - function test_cancelChallenge_noChallenge_reverts() external { + function test_respond_noChallenge_reverts() external { // Module is already enabled in setUp, no challenge exists // Try to cancel when no challenge exists - this should fail // We need to use a transaction that would work if there was a challenge // Use safeTxGas > 0 to allow the Safe to handle the revert gracefully - bytes memory data = abi.encodeCall(LivenessModule2.cancelChallenge, ()); + bytes memory data = abi.encodeCall(LivenessModule2.respond, ()); bool success = SafeTestLib.execTransaction( safeInstance, address(livenessModule2), @@ -270,10 +271,10 @@ contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { assertFalse(success, "Should fail to cancel non-existent challenge"); } - function test_cancelChallenge_afterResponsePeriod_reverts() external { + function test_respond_afterResponsePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -281,16 +282,16 @@ contract LivenessModule2_StartChallenge_Test is LivenessModule2_TestInit { // Try to cancel - should fail as response period has expired vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodExpired.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.cancelChallenge(); + livenessModule2.respond(); } - function test_cancelChallenge_moduleNotEnabled_reverts() external { + function test_respond_moduleNotEnabled_reverts() external { // Create a Safe that hasn't enabled the module address safeThatDidntEnable = makeAddr("safeThatDidntEnable"); vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(safeThatDidntEnable); - livenessModule2.cancelChallenge(); + livenessModule2.respond(); } } @@ -305,7 +306,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -342,7 +343,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); // Try to execute before response period expires vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodActive.selector); @@ -352,7 +353,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_canBeCalledByAnyone_succeeds() external { // Start a challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); @@ -371,34 +372,36 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_canRechallenge_succeeds() external { // Start and execute first challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); // Start a new challenge (as fallback owner) vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); assertGt(challengeEndTime, 0); } } -/// @title LivenessModule2_ViewConfiguration_Test -/// @notice Tests view functions -contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { - function test_viewConfiguration_succeeds() external { +/// @title LivenessModule2_IsChallenged_Test +/// @notice Tests the isChallenged function and related view functionality +contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { + function test_safeConfigs_succeeds() external { // Before enabling - (uint256 period1, address fbOwner1) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + (uint256 period1, address fbOwner1) = livenessModule2.safeConfigs(address(safeInstance.safe)); assertEq(period1, 0); assertEq(fbOwner1, address(0)); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); // After enabling _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period2, address fbOwner2) = livenessModule2.viewConfiguration(address(safeInstance.safe)); + (uint256 period2, address fbOwner2) = livenessModule2.safeConfigs(address(safeInstance.safe)); assertEq(period2, CHALLENGE_PERIOD); assertEq(fbOwner2, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } function test_isChallenged_succeeds() external { @@ -409,11 +412,11 @@ contract LivenessModule2_ViewConfiguration_Test is LivenessModule2_TestInit { // With challenge vm.prank(fallbackOwner); - livenessModule2.startChallenge(address(safeInstance.safe)); + livenessModule2.challenge(address(safeInstance.safe)); assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); // After cancellation - _cancelChallenge(safeInstance); + _respondToChallenge(safeInstance); assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); } diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index bb265fb15e4..62e02fdb22f 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -70,7 +70,7 @@ contract DeployOwnershipTest is Test, DeployOwnership { // LivenessModule2 checks LivenessModuleConfig memory lmConfig = exampleSecurityCouncilConfig.livenessModuleConfig; (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).viewConfiguration(address(securityCouncilSafe)); + LivenessModule2(livenessModule).safeConfigs(address(securityCouncilSafe)); assertEq(configuredPeriod, lmConfig.livenessInterval); assertEq(configuredFallback, lmConfig.fallbackOwner); From 37ae803b8080e1b2aa502cd33d3a24d3e0134ed2 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 06:47:47 +0200 Subject: [PATCH 15/45] alcueca's feedback --- .../interfaces/safe/ILivenessModule2.sol | 8 +- .../scripts/deploy/DeployOwnership.s.sol | 2 +- .../snapshots/abi/LivenessModule2.json | 4 +- .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 36 +++++--- .../test/safe/LivenessModule2.t.sol | 88 ++++++++----------- 6 files changed, 68 insertions(+), 74 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 247cf64ef41..2f716907b46 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -61,13 +61,13 @@ interface ILivenessModule2 is ISemver { /// @return version The contract version function version() external view returns (string memory); - /// @notice Enables the module by the multisig to be challenged + /// @notice Configures the module for a Safe that has already enabled it /// @param _livenessResponsePeriod The period in seconds for a liveness response /// @param _fallbackOwner The address that will become owner if challenge succeeds - function enableModule(uint256 _livenessResponsePeriod, address _fallbackOwner) external; + function configure(uint256 _livenessResponsePeriod, address _fallbackOwner) external; - /// @notice Disables the module by an enabled safe - function disable() external; + /// @notice Clears the module configuration for a Safe + function clear() external; /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge, or 0 if not diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index b934f0d2869..7d752bdeca4 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -309,7 +309,7 @@ contract DeployOwnership is Deploy { _safe: safe, _target: livenessModule, _data: abi.encodeCall( - LivenessModule2.enableModule, (livenessModuleConfig.livenessInterval, livenessModuleConfig.fallbackOwner) + LivenessModule2.configure, (livenessModuleConfig.livenessInterval, livenessModuleConfig.fallbackOwner) ) }); diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 76f8f803769..39f05808ffd 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -59,7 +59,7 @@ }, { "inputs": [], - "name": "disable", + "name": "clear", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -77,7 +77,7 @@ "type": "address" } ], - "name": "enableModule", + "name": "configure", "outputs": [], "stateMutability": "nonpayable", "type": "function" diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 5f61a0017d1..bf924b77fac 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0xc99a1f55949d4f5170e9e6cd3ab5c052efe1beb61b6c8e1458cebd091e705f7e", - "sourceCodeHash": "0xed873a13be41ae8093836cc00fd4b0cf9467e6e5876485ec51810ea4dfa71935" + "initCodeHash": "0x56c35c55a45675b60d8867a8335de2c478088a94347005fa94c84b42ad28a777", + "sourceCodeHash": "0xe2cf515c348dab78530cc5aa1877189b2573f9f5954bf031a3a5030f7f6df36c" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 7ee19e55505..53cb7466bb0 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -37,38 +37,46 @@ contract LivenessModule2 is ILivenessModule2 { /// @custom:semver 2.0.0 string public constant version = "2.0.0"; - /// @notice Enables the module by the multisig to be challenged and sets the liveness_response_period and - /// fallback_owner - /// @dev MUST set the caller as a safe + /// @notice Configures the module for a Safe that has already enabled it + /// @dev MUST only be callable by a Safe that has enabled this module /// @dev MUST take as parameters liveness_response_period and fallback_owner and store them as related to the safe - /// @dev MUST accept an arbitrary number of independent safe contracts to enable the module /// @param _livenessResponsePeriod The period in seconds for a liveness response /// @param _fallbackOwner The address that will become owner if challenge succeeds - function enableModule(uint256 _livenessResponsePeriod, address _fallbackOwner) external { + function configure(uint256 _livenessResponsePeriod, address _fallbackOwner) external { if (_livenessResponsePeriod == 0 || _fallbackOwner == address(0)) { revert LivenessModule2_InvalidParameters(); } - // Set the caller as a safe and store its configuration - ModuleConfig storage config = safeConfigs[msg.sender]; + // Check that this module is enabled on the calling Safe + Safe safe = Safe(payable(msg.sender)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } - // Store the parameters related to this safe + // Store the configuration for this safe + ModuleConfig storage config = safeConfigs[msg.sender]; config.livenessResponsePeriod = _livenessResponsePeriod; config.fallbackOwner = _fallbackOwner; - // Clear any existing challenge when enabling/re-enabling + // Clear any existing challenge when configuring/re-configuring delete challengeStartTime[msg.sender]; emit ModuleEnabled(msg.sender, _livenessResponsePeriod, _fallbackOwner); } - /// @notice Disables the module by an enabled safe - /// @dev MUST only be executable by an enabled safe + /// @notice Clears the module configuration for a Safe + /// @dev MUST only be executable by a Safe that has this module enabled /// @dev MUST erase the existing liveness_response_period and fallback_owner data related to the calling safe - /// @dev Note: Disabling the module also cancels any ongoing challenges - function disable() external { + /// @dev Note: Clearing the configuration also cancels any ongoing challenges + function clear() external { + // Check that this module is enabled on the calling Safe + Safe safe = Safe(payable(msg.sender)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } + ModuleConfig storage config = safeConfigs[msg.sender]; - // Check if the calling safe has the module enabled + // Check if the calling safe has configuration set if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index f8dfafcb188..9a1244a0824 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -58,7 +58,7 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { _safe, address(livenessModule2), 0, - abi.encodeCall(LivenessModule2.enableModule, (_period, _fallback)), + abi.encodeCall(LivenessModule2.configure, (_period, _fallback)), Enum.Operation.Call ); } @@ -66,7 +66,7 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @notice Helper to disable the LivenessModule2 for a Safe function _disableModule(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.disable, ()), Enum.Operation.Call + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.clear, ()), Enum.Operation.Call ); } @@ -78,10 +78,10 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { } } -/// @title LivenessModule2_EnableModule_Test -/// @notice Tests enabling and disabling the module -contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { - function test_enableModule_succeeds() external { +/// @title LivenessModule2_Configure_Test +/// @notice Tests configuring and clearing the module +contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { + function test_configure_succeeds() external { vm.expectEmit(true, true, true, true); emit ModuleEnabled(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); @@ -93,82 +93,68 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } - function test_enableModule_multipleSafes_succeeds() external { - // Test that multiple independent safes can enable the module - address safe1 = makeAddr("safe1"); - address safe2 = makeAddr("safe2"); - address safe3 = makeAddr("safe3"); + function test_configure_multipleSafes_succeeds() external { + // Test that multiple independent safes can configure the module + (, uint256[] memory keys1) = SafeTestLib.makeAddrsAndKeys("safe1", NUM_OWNERS); + SafeInstance memory safe1 = _setupSafe(keys1, THRESHOLD); + SafeTestLib.enableModule(safe1, address(livenessModule2)); + + (, uint256[] memory keys2) = SafeTestLib.makeAddrsAndKeys("safe2", NUM_OWNERS); + SafeInstance memory safe2 = _setupSafe(keys2, THRESHOLD); + SafeTestLib.enableModule(safe2, address(livenessModule2)); + + (, uint256[] memory keys3) = SafeTestLib.makeAddrsAndKeys("safe3", NUM_OWNERS); + SafeInstance memory safe3 = _setupSafe(keys3, THRESHOLD); + SafeTestLib.enableModule(safe3, address(livenessModule2)); address fallback1 = makeAddr("fallback1"); address fallback2 = makeAddr("fallback2"); address fallback3 = makeAddr("fallback3"); - // Enable module for safe1 - vm.prank(safe1); - livenessModule2.enableModule(1 days, fallback1); - - // Enable module for safe2 - vm.prank(safe2); - livenessModule2.enableModule(2 days, fallback2); - - // Enable module for safe3 - vm.prank(safe3); - livenessModule2.enableModule(3 days, fallback3); + // Configure module for each safe + _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.safeConfigs(safe1); + (uint256 period1, address fb1) = livenessModule2.safeConfigs(address(safe1.safe)); assertEq(period1, 1 days); assertEq(fb1, fallback1); - (uint256 period2, address fb2) = livenessModule2.safeConfigs(safe2); + (uint256 period2, address fb2) = livenessModule2.safeConfigs(address(safe2.safe)); assertEq(period2, 2 days); assertEq(fb2, fallback2); - (uint256 period3, address fb3) = livenessModule2.safeConfigs(safe3); + (uint256 period3, address fb3) = livenessModule2.safeConfigs(address(safe3.safe)); assertEq(period3, 3 days); assertEq(fb3, fallback3); } - function test_enableModule_requiresSafeModuleInstallation_reverts() external { + function test_configure_requiresSafeModuleInstallation_reverts() external { // Create a safe that has NOT installed the module at the Safe level (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("newSafe", NUM_OWNERS); SafeInstance memory newSafe = _setupSafe(newKeys, THRESHOLD); // Note: we don't call SafeTestLib.enableModule here - // Configure the module (this should work even without Safe-level installation) - _enableModule(newSafe, CHALLENGE_PERIOD, fallbackOwner); - - // Verify configuration was stored - (uint256 period, address fb) = livenessModule2.safeConfigs(address(newSafe.safe)); - assertEq(period, CHALLENGE_PERIOD); - assertEq(fb, fallbackOwner); - - // But trying to execute a challenge should fail because the Safe hasn't - // enabled this module at the Safe level (so execTransactionFromModule will fail) - vm.prank(fallbackOwner); - livenessModule2.challenge(address(newSafe.safe)); - - vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - - // This should fail because the module is not enabled at the Safe level - // The Safe will revert with "GS104" when execTransactionFromModule is called by non-enabled module - vm.expectRevert("GS104"); - livenessModule2.changeOwnershipToFallback(address(newSafe.safe)); + // Now configure should revert because the module is not enabled at the Safe level + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(address(newSafe.safe)); + livenessModule2.configure(CHALLENGE_PERIOD, fallbackOwner); } - function test_enableModule_invalidParameters_reverts() external { + function test_configure_invalidParameters_reverts() external { // Test with zero period vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.enableModule(0, fallbackOwner); + livenessModule2.configure(0, fallbackOwner); // Test with zero address vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.enableModule(CHALLENGE_PERIOD, address(0)); + livenessModule2.configure(CHALLENGE_PERIOD, address(0)); } - function test_disable_succeeds() external { + function test_clear_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); vm.expectEmit(true, true, true, true); @@ -181,10 +167,10 @@ contract LivenessModule2_EnableModule_Test is LivenessModule2_TestInit { assertEq(fbOwner, address(0)); } - function test_disable_notEnabled_reverts() external { + function test_clear_notEnabled_reverts() external { vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.disable(); + livenessModule2.clear(); } } From 93494c7f1f9d5b5bc02e344a99ea902f8036f3a7 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 07:00:43 +0200 Subject: [PATCH 16/45] smartcontract's feedback --- .../interfaces/safe/ILivenessModule2.sol | 11 ++++++--- .../scripts/deploy/DeployOwnership.s.sol | 9 +++++++- .../snapshots/abi/LivenessModule2.json | 23 ++++++++++++------- .../snapshots/semver-lock.json | 4 ++-- .../storageLayout/LivenessModule2.json | 2 +- .../src/safe/LivenessModule2.sol | 20 ++++------------ .../test/safe/LivenessModule2.t.sol | 20 +++++++++------- 7 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 2f716907b46..be0a39befc8 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -6,6 +6,12 @@ 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 { + + /// @notice Configuration for a Safe's liveness module + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } /// @notice Error for when module is not enabled for the Safe error LivenessModule2_ModuleNotEnabled(); @@ -62,9 +68,8 @@ interface ILivenessModule2 is ISemver { function version() external view returns (string memory); /// @notice Configures the module for a Safe that has already enabled it - /// @param _livenessResponsePeriod The period in seconds for a liveness response - /// @param _fallbackOwner The address that will become owner if challenge succeeds - function configure(uint256 _livenessResponsePeriod, address _fallbackOwner) external; + /// @param _config The configuration parameters for the module + function configure(ModuleConfig memory _config) external; /// @notice Clears the module configuration for a Safe function clear() external; diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 7d752bdeca4..0148f3d0298 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 { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; @@ -309,7 +310,13 @@ contract DeployOwnership is Deploy { _safe: safe, _target: livenessModule, _data: abi.encodeCall( - LivenessModule2.configure, (livenessModuleConfig.livenessInterval, livenessModuleConfig.fallbackOwner) + LivenessModule2.configure, + ( + ILivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessModuleConfig.livenessInterval, + fallbackOwner: livenessModuleConfig.fallbackOwner + }) + ) ) }); diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 39f05808ffd..bcd84c8c661 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -67,14 +67,21 @@ { "inputs": [ { - "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 ILivenessModule2.ModuleConfig", + "name": "_config", + "type": "tuple" } ], "name": "configure", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index bf924b77fac..47afaab3cf1 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x56c35c55a45675b60d8867a8335de2c478088a94347005fa94c84b42ad28a777", - "sourceCodeHash": "0xe2cf515c348dab78530cc5aa1877189b2573f9f5954bf031a3a5030f7f6df36c" + "initCodeHash": "0xdc804851b2dfc577af1b81885466ee7ad152d5490effa7f2a0c91c7fe5793a49", + "sourceCodeHash": "0xf7729019b1be5623eafc712ddc550d6b2b06886de542c6c1c8bf9963df9ccaa1" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json index a9f1811395c..1c538b4845d 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -4,7 +4,7 @@ "label": "safeConfigs", "offset": 0, "slot": "0", - "type": "mapping(address => struct LivenessModule2.ModuleConfig)" + "type": "mapping(address => struct ILivenessModule2.ModuleConfig)" }, { "bytes": "32", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 53cb7466bb0..4ab3c1ab01e 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -21,12 +21,6 @@ contract LivenessModule2 is ILivenessModule2 { /// @notice Reserved address used as the previous owner to the first owner in a Safe address public constant SENTINEL_OWNER = address(0x1); - /// @notice Configuration for a Safe's liveness module - struct ModuleConfig { - uint256 livenessResponsePeriod; - address fallbackOwner; - } - /// @notice Mapping from Safe address to its configuration mapping(address => ModuleConfig) public safeConfigs; @@ -39,11 +33,9 @@ contract LivenessModule2 is ILivenessModule2 { /// @notice Configures the module for a Safe that has already enabled it /// @dev MUST only be callable by a Safe that has enabled this module - /// @dev MUST take as parameters liveness_response_period and fallback_owner and store them as related to the safe - /// @param _livenessResponsePeriod The period in seconds for a liveness response - /// @param _fallbackOwner The address that will become owner if challenge succeeds - function configure(uint256 _livenessResponsePeriod, address _fallbackOwner) external { - if (_livenessResponsePeriod == 0 || _fallbackOwner == address(0)) { + /// @param _config The configuration parameters for the module + function configure(ModuleConfig memory _config) external { + if (_config.livenessResponsePeriod == 0 || _config.fallbackOwner == address(0)) { revert LivenessModule2_InvalidParameters(); } @@ -54,14 +46,12 @@ contract LivenessModule2 is ILivenessModule2 { } // Store the configuration for this safe - ModuleConfig storage config = safeConfigs[msg.sender]; - config.livenessResponsePeriod = _livenessResponsePeriod; - config.fallbackOwner = _fallbackOwner; + safeConfigs[msg.sender] = _config; // Clear any existing challenge when configuring/re-configuring delete challengeStartTime[msg.sender]; - emit ModuleEnabled(msg.sender, _livenessResponsePeriod, _fallbackOwner); + emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); } /// @notice Clears the module configuration for a Safe diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 9a1244a0824..7b811cab4a9 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -54,12 +54,10 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @notice Helper to enable the LivenessModule2 for a Safe function _enableModule(SafeInstance memory _safe, uint256 _period, address _fallback) internal { + ILivenessModule2.ModuleConfig memory config = + ILivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); SafeTestLib.execTransaction( - _safe, - address(livenessModule2), - 0, - abi.encodeCall(LivenessModule2.configure, (_period, _fallback)), - Enum.Operation.Call + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.configure, (config)), Enum.Operation.Call ); } @@ -139,19 +137,25 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { // Now configure should revert because the module is not enabled at the Safe level vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(newSafe.safe)); - livenessModule2.configure(CHALLENGE_PERIOD, fallbackOwner); + livenessModule2.configure( + ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) + ); } function test_configure_invalidParameters_reverts() external { // Test with zero period vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.configure(0, fallbackOwner); + livenessModule2.configure( + ILivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) + ); // Test with zero address vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.configure(CHALLENGE_PERIOD, address(0)); + livenessModule2.configure( + ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) + ); } function test_clear_succeeds() external { From f5fb1d39191c2494752f01f9539faf8bf1002e29 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 07:45:38 +0200 Subject: [PATCH 17/45] add extra checks suggested by alcueca --- .../scripts/checks/check-frozen-files.sh | 136 ------------------ .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 44 ++++-- .../test/safe/LivenessModule2.t.sol | 16 ++- 4 files changed, 48 insertions(+), 152 deletions(-) delete mode 100755 packages/contracts-bedrock/scripts/checks/check-frozen-files.sh diff --git a/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh b/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh deleted file mode 100755 index 50522e9568f..00000000000 --- a/packages/contracts-bedrock/scripts/checks/check-frozen-files.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Grab the directory of the contracts-bedrock package. -SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) - -# Load semver-utils. -# shellcheck source=/dev/null -source "$SCRIPT_DIR/utils/semver-utils.sh" - -# Path to semver-lock.json. -SEMVER_LOCK="snapshots/semver-lock.json" - -# Create a temporary directory. -temp_dir=$(mktemp -d) -trap 'rm -rf "$temp_dir"' EXIT - -# Exit early if semver-lock.json has not changed. -if ! { git diff origin/develop...HEAD --name-only; git diff --name-only; git diff --cached --name-only; } | grep -q "$SEMVER_LOCK"; then - echo "No changes detected in semver-lock.json" - exit 0 -fi - -# Get the upstream semver-lock.json. -if ! git show origin/develop:packages/contracts-bedrock/snapshots/semver-lock.json > "$temp_dir/upstream_semver_lock.json" 2>/dev/null; then - echo "❌ Error: Could not find semver-lock.json in the snapshots/ directory of develop branch" - exit 1 -fi - -# Copy the local semver-lock.json. -cp "$SEMVER_LOCK" "$temp_dir/local_semver_lock.json" - -# Get the changed contracts. -changed_contracts=$(jq -r ' - def changes: - to_entries as $local - | input as $upstream - | $local | map( - select( - .key as $key - | .value != $upstream[$key] - ) - ) | map(.key); - changes[] -' "$temp_dir/local_semver_lock.json" "$temp_dir/upstream_semver_lock.json") - -# List of files that are allowed to be modified. -# In order to prevent a file from being modified, comment it out. Do not delete it. -# All files in semver-lock.json should be in this list. -ALLOWED_FILES=( - "src/L1/OPContractsManagerStandardValidator.sol:OPContractsManagerStandardValidator" - "src/L1/DataAvailabilityChallenge.sol:DataAvailabilityChallenge" - # "src/L1/ETHLockbox.sol:ETHLockbox" - "src/L1/L1CrossDomainMessenger.sol:L1CrossDomainMessenger" - "src/L1/L1ERC721Bridge.sol:L1ERC721Bridge" - "src/L1/L1StandardBridge.sol:L1StandardBridge" - "src/L1/OPContractsManager.sol:OPContractsManager" - # "src/L1/OptimismPortal2.sol:OptimismPortal2" - "src/L1/ProtocolVersions.sol:ProtocolVersions" - "src/L1/SuperchainConfig.sol:SuperchainConfig" - "src/L1/SystemConfig.sol:SystemConfig" - "src/L2/BaseFeeVault.sol:BaseFeeVault" - "src/L2/CrossL2Inbox.sol:CrossL2Inbox" - "src/L2/ETHLiquidity.sol:ETHLiquidity" - "src/L2/GasPriceOracle.sol:GasPriceOracle" - "src/L2/L1Block.sol:L1Block" - "src/L2/L1FeeVault.sol:L1FeeVault" - "src/L2/L2CrossDomainMessenger.sol:L2CrossDomainMessenger" - "src/L2/L2ERC721Bridge.sol:L2ERC721Bridge" - "src/L2/L2StandardBridge.sol:L2StandardBridge" - "src/L2/L2StandardBridgeInterop.sol:L2StandardBridgeInterop" - "src/L2/L2ToL1MessagePasser.sol:L2ToL1MessagePasser" - "src/L2/L2ToL2CrossDomainMessenger.sol:L2ToL2CrossDomainMessenger" - "src/L2/OptimismMintableERC721.sol:OptimismMintableERC721" - "src/L2/OptimismMintableERC721Factory.sol:OptimismMintableERC721Factory" - "src/L2/OptimismSuperchainERC20.sol:OptimismSuperchainERC20" - "src/L2/OptimismSuperchainERC20Beacon.sol:OptimismSuperchainERC20Beacon" - "src/L2/OptimismSuperchainERC20Factory.sol:OptimismSuperchainERC20Factory" - "src/L2/SequencerFeeVault.sol:SequencerFeeVault" - "src/L2/SuperchainERC20.sol:SuperchainERC20" - "src/L2/SuperchainTokenBridge.sol:SuperchainTokenBridge" - "src/L2/SuperchainETHBridge.sol:SuperchainETHBridge" - "src/L2/WETH.sol:WETH" - "src/cannon/MIPS64.sol:MIPS64" - "src/cannon/PreimageOracle.sol:PreimageOracle" - # "src/dispute/AnchorStateRegistry.sol:AnchorStateRegistry" - "src/dispute/DelayedWETH.sol:DelayedWETH" - # "src/dispute/DisputeGameFactory.sol:DisputeGameFactory" - "src/dispute/FaultDisputeGame.sol:FaultDisputeGame" - "src/dispute/v2/FaultDisputeGameV2.sol:FaultDisputeGameV2" - "src/dispute/PermissionedDisputeGame.sol:PermissionedDisputeGame" - "src/dispute/SuperFaultDisputeGame.sol:SuperFaultDisputeGame" - "src/dispute/SuperPermissionedDisputeGame.sol:SuperPermissionedDisputeGame" - "src/legacy/DeployerWhitelist.sol:DeployerWhitelist" - "src/legacy/L1BlockNumber.sol:L1BlockNumber" - "src/legacy/LegacyMessagePasser.sol:LegacyMessagePasser" - "src/safe/DeputyGuardianModule.sol:DeputyGuardianModule" - "src/safe/DeputyPauseModule.sol:DeputyPauseModule" - "src/safe/LivenessGuard.sol:LivenessGuard" - "src/safe/LivenessModule.sol:LivenessModule" - "src/safe/LivenessModule2.sol:LivenessModule2" - "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20" - "src/universal/OptimismMintableERC20Factory.sol:OptimismMintableERC20Factory" - "src/universal/StorageSetter.sol:StorageSetter" - "src/vendor/asterisc/RISCV.sol:RISCV" - "src/vendor/eas/EAS.sol:EAS" - "src/vendor/eas/SchemaRegistry.sol:SchemaRegistry" -) - -MATCHED_FILES=() -# Check each changed contract against allowed patterns -for contract in $changed_contracts; do - is_allowed=false - for allowed_file in "${ALLOWED_FILES[@]}"; do - if [[ "$contract" == "$allowed_file" ]]; then - is_allowed=true - break - fi - done - if [[ "$is_allowed" == "false" ]]; then - MATCHED_FILES+=("$contract") - fi -done - -if [ ${#MATCHED_FILES[@]} -gt 0 ]; then - echo "❌ Error: Changes detected in files that are not allowed to be modified." - echo "The following files were modified but are not in the allowed list:" - printf ' - %s\n' "${MATCHED_FILES[@]}" - echo "Only the following files can be modified:" - printf ' - %s\n' "${ALLOWED_FILES[@]}" - echo "The code freeze is expected to be lifted no later than 2025-02-20." - exit 1 -fi - -echo "✅ All changes are in allowed files" -exit 0 diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 47afaab3cf1..37e0ae07816 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0xdc804851b2dfc577af1b81885466ee7ad152d5490effa7f2a0c91c7fe5793a49", - "sourceCodeHash": "0xf7729019b1be5623eafc712ddc550d6b2b06886de542c6c1c8bf9963df9ccaa1" + "initCodeHash": "0x447ee762f557b01d36b2403627316e474ba5f28a7d44343466f9f10fd60b6700", + "sourceCodeHash": "0xfe89fb909765f21da90fe621a82e7a8624d65b2114df804eca1c3e27478e466c" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 4ab3c1ab01e..8e5564e20a1 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -55,19 +55,20 @@ contract LivenessModule2 is ILivenessModule2 { } /// @notice Clears the module configuration for a Safe - /// @dev MUST only be executable by a Safe that has this module enabled + /// @dev MUST only be executable by a Safe that has DISABLED this module first /// @dev MUST erase the existing liveness_response_period and fallback_owner data related to the calling safe /// @dev Note: Clearing the configuration also cancels any ongoing challenges function clear() external { - // Check that this module is enabled on the calling Safe - Safe safe = Safe(payable(msg.sender)); - if (!safe.isModuleEnabled(address(this))) { + // Check if the calling safe has configuration set + ModuleConfig storage config = safeConfigs[msg.sender]; + if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); } - ModuleConfig storage config = safeConfigs[msg.sender]; - // Check if the calling safe has configuration set - if (config.fallbackOwner == address(0)) { + // Check that this module is NOT enabled on the calling Safe + // This prevents clearing configuration while module is still enabled + Safe safe = Safe(payable(msg.sender)); + if (safe.isModuleEnabled(address(this))) { revert LivenessModule2_ModuleNotEnabled(); } @@ -105,6 +106,12 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_ModuleNotEnabled(); } + // Check that the module is still enabled on the target Safe + Safe safe = Safe(payable(_safe)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } + if (msg.sender != config.fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -123,8 +130,15 @@ contract LivenessModule2 is ILivenessModule2 { /// @dev MUST revert if there is a challenge for the calling safe but the response period has expired /// @dev MUST emit the ChallengeCancelled event function respond() external { + // Check that this module is enabled on the calling Safe + Safe safe = Safe(payable(msg.sender)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } + ModuleConfig storage config = safeConfigs[msg.sender]; + // Check if the calling safe has configuration set if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotEnabled(); } @@ -158,6 +172,12 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_ModuleNotEnabled(); } + // Check that the module is still enabled on the target Safe + Safe safe = Safe(payable(_safe)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } + uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); @@ -168,26 +188,26 @@ contract LivenessModule2 is ILivenessModule2 { revert LivenessModule2_ResponsePeriodActive(); } - Safe safe = Safe(payable(_safe)); + Safe targetSafe = Safe(payable(_safe)); // Get current owners - address[] memory owners = safe.getOwners(); + address[] memory owners = targetSafe.getOwners(); // Remove all owners after the first one while (owners.length > 1) { // removeOwner automatically updates the threshold, so we don't need to do it manually - safe.execTransactionFromModule({ + targetSafe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, data: abi.encodeCall(OwnerManager.removeOwner, (SENTINEL_OWNER, owners[0], 1)) }); // Get updated owners list after removal - owners = safe.getOwners(); + owners = targetSafe.getOwners(); } // Now swap the remaining single owner with the fallback owner - safe.execTransactionFromModule({ + targetSafe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 7b811cab4a9..8cf0d78e066 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -161,9 +161,19 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { function test_clear_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + // First disable the module at the Safe level + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + vm.expectEmit(true, true, true, true); emit ModuleDisabled(address(safeInstance.safe)); + // Now clear the configuration _disableModule(safeInstance); (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); @@ -277,10 +287,12 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function test_respond_moduleNotEnabled_reverts() external { // Create a Safe that hasn't enabled the module - address safeThatDidntEnable = makeAddr("safeThatDidntEnable"); + (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("safeThatDidntEnable", NUM_OWNERS); + SafeInstance memory safeThatDidntEnable = _setupSafe(newKeys, THRESHOLD); + // Note: we don't call SafeTestLib.enableModule here vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); - vm.prank(safeThatDidntEnable); + vm.prank(address(safeThatDidntEnable.safe)); livenessModule2.respond(); } } From ee4de98d5e0a599e107ac36d4d708b495f1dc28e Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 07:55:59 +0200 Subject: [PATCH 18/45] fix clear error --- .../interfaces/safe/ILivenessModule2.sol | 3 +++ packages/contracts-bedrock/src/safe/LivenessModule2.sol | 2 +- .../contracts-bedrock/test/safe/LivenessModule2.t.sol | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index be0a39befc8..7a65e4dd06d 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -34,6 +34,9 @@ interface ILivenessModule2 is ISemver { /// @notice Error for invalid parameters error LivenessModule2_InvalidParameters(); + /// @notice Error for when trying to clear configuration while module is still enabled + error LivenessModule2_ModuleStillEnabled(); + /// @notice Emitted when a Safe enables the module event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 8e5564e20a1..d6c8597140d 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -69,7 +69,7 @@ contract LivenessModule2 is ILivenessModule2 { // This prevents clearing configuration while module is still enabled Safe safe = Safe(payable(msg.sender)); if (safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleNotEnabled(); + revert LivenessModule2_ModuleStillEnabled(); } // Erase the configuration data for this safe diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 8cf0d78e066..9c1f2ed00ca 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -186,6 +186,15 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { vm.prank(address(safeInstance.safe)); livenessModule2.clear(); } + + function test_clear_moduleStillEnabled_reverts() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Try to clear while module is still enabled (should revert) + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleStillEnabled.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.clear(); + } } /// @title LivenessModule2_Challenge_Test From e971a46b07a337cf86d5f13bd91d9c2bea08d787 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 08:02:21 +0200 Subject: [PATCH 19/45] update snapshots --- .../contracts-bedrock/snapshots/abi/LivenessModule2.json | 5 +++++ packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index bcd84c8c661..9cc18b3fd7b 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -261,6 +261,11 @@ "name": "LivenessModule2_ModuleNotEnabled", "type": "error" }, + { + "inputs": [], + "name": "LivenessModule2_ModuleStillEnabled", + "type": "error" + }, { "inputs": [], "name": "LivenessModule2_ResponsePeriodActive", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 37e0ae07816..f42a1aae3cf 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x447ee762f557b01d36b2403627316e474ba5f28a7d44343466f9f10fd60b6700", - "sourceCodeHash": "0xfe89fb909765f21da90fe621a82e7a8624d65b2114df804eca1c3e27478e466c" + "initCodeHash": "0x7bbf87d1a2a739a1b0a09d66e073c25dd2146f05d85cac111a48598a52b0c751", + "sourceCodeHash": "0xfe6ad11de353e63c76a8669288b3a37446d71aeed34d0aa2fab4011d638cce99" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", From 9c0f85cc254bedf9725159fe2d582199eb0fc6d3 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 08:14:34 +0200 Subject: [PATCH 20/45] conflicts --- .circleci/config.yml | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 883519a661d..8b19dea6686 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -682,42 +682,6 @@ jobs: docker pull $image_name || exit 1 docker run $image_name <> --version || exit 1 - - contracts-bedrock-frozen-code: - machine: true - resource_class: ethereum-optimism/latitude-1 - steps: - - checkout-from-workspace - - check-changed: - patterns: contracts-bedrock - - get-target-branch - - run: - name: Check if target branch is develop - command: | - # If the target branch is not develop, do not run this check - if [ "${TARGET_BRANCH}" != "develop" ]; then - echo "Target branch is not develop, skipping frozen files check" - circleci-agent step halt - fi - - run: - name: Check if PR has exempt label - command: | - # Get PR number from CIRCLE_PULL_REQUEST - PR_NUMBER=$(echo $CIRCLE_PULL_REQUEST | rev | cut -d/ -f1 | rev) - - # Use GitHub API to get labels - LABELS=$(curl -s "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${PR_NUMBER}" | jq -r .labels) - - # If the PR has the "M-exempt-frozen-files" label, do not run this check - if echo $LABELS | jq -e 'any(.[]; .name == "M-exempt-frozen-files")' > /dev/null; then - echo "Skipping frozen files check, PR has exempt label" - circleci-agent step halt - fi - - run: - name: Check frozen files - command: just check-frozen-code - working_directory: packages/contracts-bedrock - contracts-bedrock-tests: circleci_ip_ranges: true docker: @@ -1926,11 +1890,6 @@ workflows: - contracts-bedrock-build context: - circleci-repo-readonly-authenticated-github-token - - contracts-bedrock-frozen-code: - requires: - - contracts-bedrock-build - context: - - circleci-repo-readonly-authenticated-github-token - diff-fetcher-forge-artifacts: context: - circleci-repo-readonly-authenticated-github-token From 6a29e0deaf569a199cbb0a1a1b0b1bef40ee41cb Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 09:03:14 +0200 Subject: [PATCH 21/45] name nitpicks and coverage at 100% --- .../interfaces/safe/ILivenessModule2.sol | 7 ++- .../snapshots/abi/LivenessModule2.json | 7 ++- .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 10 ++-- .../test/safe/LivenessModule2.t.sol | 53 +++++++++++++++++-- 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 7a65e4dd06d..71219303848 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -16,14 +16,17 @@ interface ILivenessModule2 is ISemver { /// @notice Error for when module is not enabled for the Safe error LivenessModule2_ModuleNotEnabled(); + /// @notice Error for when Safe is not configured for this module + error LivenessModule2_ModuleNotConfigured(); + /// @notice Error for when a challenge already exists error LivenessModule2_ChallengeAlreadyExists(); /// @notice Error for when no challenge exists error LivenessModule2_ChallengeDoesNotExist(); - /// @notice Error for when trying to cancel a challenge after the response period has expired - error LivenessModule2_ResponsePeriodExpired(); + /// @notice Error for when trying to cancel a challenge after the response period has ended + error LivenessModule2_ResponsePeriodEnded(); /// @notice Error for when trying to execute ownership transfer while the response period is still active error LivenessModule2_ResponsePeriodActive(); diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 9cc18b3fd7b..d16024d3e58 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -256,6 +256,11 @@ "name": "LivenessModule2_InvalidParameters", "type": "error" }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotConfigured", + "type": "error" + }, { "inputs": [], "name": "LivenessModule2_ModuleNotEnabled", @@ -273,7 +278,7 @@ }, { "inputs": [], - "name": "LivenessModule2_ResponsePeriodExpired", + "name": "LivenessModule2_ResponsePeriodEnded", "type": "error" }, { diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index f42a1aae3cf..e6a58c9bcc1 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x7bbf87d1a2a739a1b0a09d66e073c25dd2146f05d85cac111a48598a52b0c751", - "sourceCodeHash": "0xfe6ad11de353e63c76a8669288b3a37446d71aeed34d0aa2fab4011d638cce99" + "initCodeHash": "0x8470c0e119da2ab66abe046d7603268b4cc7e43749793ff7d65ef3bac4f04033", + "sourceCodeHash": "0xea4eb96f0866013e739a15467a1429b89b03f89cc3950550a464e5b77e61c21b" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index d6c8597140d..905207697a6 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -62,7 +62,7 @@ contract LivenessModule2 is ILivenessModule2 { // Check if the calling safe has configuration set ModuleConfig storage config = safeConfigs[msg.sender]; if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotEnabled(); + revert LivenessModule2_ModuleNotConfigured(); } // Check that this module is NOT enabled on the calling Safe @@ -103,7 +103,7 @@ contract LivenessModule2 is ILivenessModule2 { ModuleConfig storage config = safeConfigs[_safe]; if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotEnabled(); + revert LivenessModule2_ModuleNotConfigured(); } // Check that the module is still enabled on the target Safe @@ -140,7 +140,7 @@ contract LivenessModule2 is ILivenessModule2 { // Check if the calling safe has configuration set if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotEnabled(); + revert LivenessModule2_ModuleNotConfigured(); } uint256 startTime = challengeStartTime[msg.sender]; @@ -150,7 +150,7 @@ contract LivenessModule2 is ILivenessModule2 { // Check if response period has expired if (block.timestamp >= startTime + config.livenessResponsePeriod) { - revert LivenessModule2_ResponsePeriodExpired(); + revert LivenessModule2_ResponsePeriodEnded(); } delete challengeStartTime[msg.sender]; @@ -169,7 +169,7 @@ contract LivenessModule2 is ILivenessModule2 { ModuleConfig storage config = safeConfigs[_safe]; if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotEnabled(); + revert LivenessModule2_ModuleNotConfigured(); } // Check that the module is still enabled on the target Safe diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 9c1f2ed00ca..dd6da2a04c9 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -182,7 +182,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { } function test_clear_notEnabled_reverts() external { - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeInstance.safe)); livenessModule2.clear(); } @@ -227,7 +227,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function test_challenge_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(fallbackOwner); livenessModule2.challenge(newSafe); } @@ -241,6 +241,29 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { livenessModule2.challenge(address(safeInstance.safe)); } + function test_challenge_moduleDisabledAtSafeLevel_reverts() external { + // Create a Safe, configure it, then disable the module at Safe level + (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("disabledSafe", NUM_OWNERS); + SafeInstance memory disabledSafe = _setupSafe(newKeys, THRESHOLD); + + // Enable module and configure + _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner); + + // Now disable the module at Safe level (but keep config) + SafeTestLib.execTransaction( + disabledSafe, + address(disabledSafe.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Try to challenge - should revert because module is disabled at Safe level + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(fallbackOwner); + livenessModule2.challenge(address(disabledSafe.safe)); + } + function test_respond_succeeds() external { // Start a challenge vm.prank(fallbackOwner); @@ -289,7 +312,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); // Try to cancel - should fail as response period has expired - vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodExpired.selector); + vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodEnded.selector); vm.prank(address(safeInstance.safe)); livenessModule2.respond(); } @@ -342,7 +365,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); livenessModule2.changeOwnershipToFallback(newSafe); } @@ -361,6 +384,28 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI 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.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + function test_changeOwnershipToFallback_canBeCalledByAnyone_succeeds() external { // Start a challenge vm.prank(fallbackOwner); From 39f603c9d304e061643ed21b39a586bcfbb614eb Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 09:22:20 +0200 Subject: [PATCH 22/45] fix tests --- .../test/safe/LivenessModule2.t.sol | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index dd6da2a04c9..6304ffb1054 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -63,6 +63,16 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @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.clear, ()), Enum.Operation.Call ); @@ -174,7 +184,9 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { emit ModuleDisabled(address(safeInstance.safe)); // Now clear the configuration - _disableModule(safeInstance); + SafeTestLib.execTransaction( + safeInstance, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.clear, ()), Enum.Operation.Call + ); (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); assertEq(period, 0); @@ -246,7 +258,10 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("disabledSafe", NUM_OWNERS); SafeInstance memory disabledSafe = _setupSafe(newKeys, THRESHOLD); - // Enable module and configure + // First enable module at Safe level + SafeTestLib.enableModule(disabledSafe, address(livenessModule2)); + + // Then configure _enableModule(disabledSafe, CHALLENGE_PERIOD, fallbackOwner); // Now disable the module at Safe level (but keep config) @@ -327,6 +342,20 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.prank(address(safeThatDidntEnable.safe)); livenessModule2.respond(); } + + function test_respond_moduleNotConfigured_reverts() external { + // Create a Safe that has the module enabled but never configured + (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("safeNotConfigured", NUM_OWNERS); + SafeInstance memory safeNotConfigured = _setupSafe(newKeys, THRESHOLD); + + // Enable the module at Safe level but don't configure it + SafeTestLib.enableModule(safeNotConfigured, address(livenessModule2)); + // Note: we don't call _enableModule to configure it + + vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.prank(address(safeNotConfigured.safe)); + livenessModule2.respond(); + } } /// @title LivenessModule2_ChangeOwnershipToFallback_Test From f0abb5498a492e517c1c39f5a1ec354925c4c682 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 09:45:25 +0200 Subject: [PATCH 23/45] remove interface extra lines --- .../contracts-bedrock/interfaces/safe/ILivenessModule2.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 71219303848..e5be4ac3501 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -6,7 +6,7 @@ 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 { - + /// @notice Configuration for a Safe's liveness module struct ModuleConfig { uint256 livenessResponsePeriod; @@ -40,7 +40,6 @@ interface ILivenessModule2 is ISemver { /// @notice Error for when trying to clear configuration while module is still enabled error LivenessModule2_ModuleStillEnabled(); - /// @notice Emitted when a Safe enables the module event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); @@ -80,7 +79,6 @@ interface ILivenessModule2 is ISemver { /// @notice Clears the module configuration for a Safe function clear() 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 From 65454245825ba5d1e4d4d44b46774e24ee4e7f03 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 09:47:15 +0200 Subject: [PATCH 24/45] fix event on 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 6304ffb1054..fbbed68fb2d 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -14,7 +14,7 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { using SafeTestLib for SafeInstance; // Events - event ModuleEnabled(address indexed safe, uint256 livenessChallengePeriod, address fallbackOwner); + event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); event ModuleDisabled(address indexed safe); event ChallengeStarted(address indexed safe, uint256 challengeStartTime); event ChallengeCancelled(address indexed safe); From c670f52a4c42ced5bfa8b3f5c78769c08f596d14 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 10:06:40 +0200 Subject: [PATCH 25/45] rename isChallenged function to getChallengePeriodEnd --- .../interfaces/safe/ILivenessModule2.sol | 2 +- .../src/safe/LivenessModule2.sol | 2 +- .../test/safe/LivenessModule2.t.sol | 18 +++++++++--------- .../test/scripts/DeployOwnership.t.sol | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index e5be4ac3501..bcba80a024b 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -82,7 +82,7 @@ interface ILivenessModule2 is ISemver { /// @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 isChallenged(address _safe) external view returns (uint256); + function getChallengePeriodEnd(address _safe) external view returns (uint256); /// @notice Challenges an enabled safe /// @param _safe The Safe to challenge diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 905207697a6..9663cbc80c9 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -84,7 +84,7 @@ contract LivenessModule2 is ILivenessModule2 { /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The challenge end timestamp, or 0 if no challenge - function isChallenged(address _safe) external view returns (uint256) { + function getChallengePeriodEnd(address _safe) external view returns (uint256) { uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { return 0; diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index fbbed68fb2d..ea5b6a7212f 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -224,7 +224,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); - uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertEq(challengeEndTime, block.timestamp + CHALLENGE_PERIOD); } @@ -291,7 +291,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { _respondToChallenge(safeInstance); // Verify challenge is cancelled - uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertEq(challengeEndTime, 0); } @@ -387,7 +387,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI assertEq(safeInstance.safe.getThreshold(), 1); // Verify challenge is reset - uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertEq(challengeEndTime, 0); } @@ -466,13 +466,13 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); - uint256 challengeEndTime = livenessModule2.isChallenged(address(safeInstance.safe)); + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); assertGt(challengeEndTime, 0); } } /// @title LivenessModule2_IsChallenged_Test -/// @notice Tests the isChallenged function and related view functionality +/// @notice Tests the getChallengePeriodEnd function and related view functionality contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { function test_safeConfigs_succeeds() external { // Before enabling @@ -489,20 +489,20 @@ contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } - function test_isChallenged_succeeds() external { + function test_getChallengePeriodEnd_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // No challenge - assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); // With challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); - assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); // After cancellation _respondToChallenge(safeInstance); - assertEq(livenessModule2.isChallenged(address(safeInstance.safe)), 0); + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); } function test_version_succeeds() external view { diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index 62e02fdb22f..5d5b57b5d8a 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -75,6 +75,6 @@ contract DeployOwnershipTest is Test, DeployOwnership { assertEq(configuredFallback, lmConfig.fallbackOwner); // Verify no active challenge exists initially - assertEq(LivenessModule2(livenessModule).isChallenged(address(securityCouncilSafe)), 0); + assertEq(LivenessModule2(livenessModule).getChallengePeriodEnd(address(securityCouncilSafe)), 0); } } From 737affc77f43cd69fadb53bf5c7505fd49b601d9 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 2 Sep 2025 10:13:16 +0200 Subject: [PATCH 26/45] update snapshots --- packages/contracts-bedrock/snapshots/abi/LivenessModule2.json | 2 +- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/test/safe/LivenessModule2.t.sol | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index d16024d3e58..9ce6a597023 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -97,7 +97,7 @@ "type": "address" } ], - "name": "isChallenged", + "name": "getChallengePeriodEnd", "outputs": [ { "internalType": "uint256", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index e6a58c9bcc1..f9288839be5 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x8470c0e119da2ab66abe046d7603268b4cc7e43749793ff7d65ef3bac4f04033", - "sourceCodeHash": "0xea4eb96f0866013e739a15467a1429b89b03f89cc3950550a464e5b77e61c21b" + "initCodeHash": "0xed857cbcf0be72ddfe01c1d2a2760d09e40af3721b29b5f13e0b659449193444", + "sourceCodeHash": "0xe7ee6c157752d526cdb0c308e83bcb7fbc379afb5134c1c3cf70c208db222f92" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index ea5b6a7212f..33ef684f8b7 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -471,9 +471,9 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI } } -/// @title LivenessModule2_IsChallenged_Test +/// @title LivenessModule2_GetChallengePeriodEnd_Test /// @notice Tests the getChallengePeriodEnd function and related view functionality -contract LivenessModule2_IsChallenged_Test is LivenessModule2_TestInit { +contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit { function test_safeConfigs_succeeds() external { // Before enabling (uint256 period1, address fbOwner1) = livenessModule2.safeConfigs(address(safeInstance.safe)); From f67131314d235c30615eee3db113f6ecb9218f1b Mon Sep 17 00:00:00 2001 From: JosepBove Date: Thu, 4 Sep 2025 12:49:01 +0200 Subject: [PATCH 27/45] update implementation from specs --- .../snapshots/semver-lock.json | 4 +-- .../src/safe/LivenessModule2.sol | 6 +++- .../test/safe/LivenessModule2.t.sol | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index f9288839be5..a1dda5a893c 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0xed857cbcf0be72ddfe01c1d2a2760d09e40af3721b29b5f13e0b659449193444", - "sourceCodeHash": "0xe7ee6c157752d526cdb0c308e83bcb7fbc379afb5134c1c3cf70c208db222f92" + "initCodeHash": "0x4a78856478160fcb2b9dc760471bfcb49cb4943f3c24cb5ce4a7128b3b07a023", + "sourceCodeHash": "0x791a41510b5f944802ebb6ed2a8cd62943f4042d55df6a2e4d9a3b4e7dde20de" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 9663cbc80c9..8f30b1d21a6 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -49,7 +49,11 @@ contract LivenessModule2 is ILivenessModule2 { safeConfigs[msg.sender] = _config; // Clear any existing challenge when configuring/re-configuring - delete challengeStartTime[msg.sender]; + // If a challenge exists, it MUST be canceled, including emitting the appropriate events + if (challengeStartTime[msg.sender] != 0) { + delete challengeStartTime[msg.sender]; + emit ChallengeCancelled(msg.sender); + } emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 33ef684f8b7..1bf1465b7bd 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -168,6 +168,34 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { ); } + function test_configure_cancelsExistingChallenge_succeeds() external { + // First configure the module + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Verify challenge exists + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertGt(challengeEndTime, 0); + + // Reconfigure the module, which should cancel the challenge and emit ChallengeCancelled + vm.expectEmit(true, true, true, true); + emit ChallengeCancelled(address(safeInstance.safe)); + vm.expectEmit(true, true, true, true); + emit ModuleEnabled(address(safeInstance.safe), CHALLENGE_PERIOD * 2, fallbackOwner); + + vm.prank(address(safeInstance.safe)); + livenessModule2.configure( + ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD * 2, fallbackOwner: fallbackOwner }) + ); + + // Verify challenge was cancelled + challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + } + function test_clear_succeeds() external { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); From 669f2e16e6ceb96f1e2aeedaf8cd69e4f047f57a Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 14:58:49 +0200 Subject: [PATCH 28/45] sentinel owner and interface fix --- .../interfaces/safe/ILivenessModule2.sol | 54 +----------------- .../scripts/deploy/DeployOwnership.s.sol | 3 +- .../snapshots/abi/LivenessModule2.json | 15 +---- .../snapshots/semver-lock.json | 4 +- .../storageLayout/LivenessModule2.json | 2 +- .../src/safe/LivenessModule2.sol | 56 +++++++++++++++++-- .../test/safe/LivenessModule2.t.sol | 45 ++++++++------- 7 files changed, 80 insertions(+), 99 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index bcba80a024b..44f6b12fa82 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -7,58 +7,6 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// @notice Interface for LivenessModule2, a singleton module for challenge-based ownership transfer interface ILivenessModule2 is ISemver { - /// @notice Configuration for a Safe's liveness module - struct ModuleConfig { - uint256 livenessResponsePeriod; - address fallbackOwner; - } - - /// @notice Error for when module is not enabled for the Safe - error LivenessModule2_ModuleNotEnabled(); - - /// @notice Error for when Safe is not configured for this module - error LivenessModule2_ModuleNotConfigured(); - - /// @notice Error for when a challenge already exists - error LivenessModule2_ChallengeAlreadyExists(); - - /// @notice Error for when no challenge exists - error LivenessModule2_ChallengeDoesNotExist(); - - /// @notice Error for when trying to cancel a challenge after the response period has ended - error LivenessModule2_ResponsePeriodEnded(); - - /// @notice Error for when trying to execute ownership transfer while the response period is still active - error LivenessModule2_ResponsePeriodActive(); - - /// @notice Error for when caller is not authorized - error LivenessModule2_UnauthorizedCaller(); - - /// @notice Error for invalid parameters - error LivenessModule2_InvalidParameters(); - - /// @notice Error for when trying to clear configuration while module is still enabled - error LivenessModule2_ModuleStillEnabled(); - - /// @notice Emitted when a Safe enables the module - event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); - - /// @notice Emitted when a Safe disables the module - event ModuleDisabled(address indexed safe); - - /// @notice Emitted when a challenge is started - event ChallengeStarted(address indexed safe, uint256 challengeStartTime); - - /// @notice Emitted when a challenge is cancelled - event ChallengeCancelled(address indexed safe); - - /// @notice Emitted when ownership is transferred to the fallback owner - event ChallengeExecuted(address indexed safe, address fallbackOwner); - - /// @notice Reserved address used as the previous owner to the first owner in a Safe - /// @return The sentinel owner address (0x1) - function SENTINEL_OWNER() external view returns (address); - /// @notice Returns the configuration for a Safe /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address @@ -74,7 +22,7 @@ interface ILivenessModule2 is ISemver { /// @notice Configures the module for a Safe that has already enabled it /// @param _config The configuration parameters for the module - function configure(ModuleConfig memory _config) external; + ///function configure(ModuleConfig memory _config) external; /// @notice Clears the module configuration for a Safe function clear() external; diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 0148f3d0298..7b303c82a4c 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -12,7 +12,6 @@ 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 { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; @@ -312,7 +311,7 @@ contract DeployOwnership is Deploy { _data: abi.encodeCall( LivenessModule2.configure, ( - ILivenessModule2.ModuleConfig({ + LivenessModule2.ModuleConfig({ livenessResponsePeriod: livenessModuleConfig.livenessInterval, fallbackOwner: livenessModuleConfig.fallbackOwner }) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 9ce6a597023..7fd1a9995ff 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -1,17 +1,4 @@ [ - { - "inputs": [], - "name": "SENTINEL_OWNER", - "outputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -79,7 +66,7 @@ "type": "address" } ], - "internalType": "struct ILivenessModule2.ModuleConfig", + "internalType": "struct LivenessModule2.ModuleConfig", "name": "_config", "type": "tuple" } diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index a1dda5a893c..21909019685 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x4a78856478160fcb2b9dc760471bfcb49cb4943f3c24cb5ce4a7128b3b07a023", - "sourceCodeHash": "0x791a41510b5f944802ebb6ed2a8cd62943f4042d55df6a2e4d9a3b4e7dde20de" + "initCodeHash": "0x34909faff5fd1ad321f86825ceda4542a9708a6d36ba35f356577a3df10a9f2c", + "sourceCodeHash": "0xa6f0568c42b23f4afa49c3df77de2e661db93e6e413d01a5fc543205248413c2" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json index 1c538b4845d..a9f1811395c 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -4,7 +4,7 @@ "label": "safeConfigs", "offset": 0, "slot": "0", - "type": "mapping(address => struct ILivenessModule2.ModuleConfig)" + "type": "mapping(address => struct LivenessModule2.ModuleConfig)" }, { "bytes": "32", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 8f30b1d21a6..45d1df67fe9 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -7,7 +7,7 @@ import { Enum } from "safe-contracts/common/Enum.sol"; import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; // Interfaces -import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; /// @title LivenessModule2 /// @notice This module allows for challenge-based ownership transfer to a fallback owner @@ -17,9 +17,12 @@ import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.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 enableModule() with parameters -contract LivenessModule2 is ILivenessModule2 { - /// @notice Reserved address used as the previous owner to the first owner in a Safe - address public constant SENTINEL_OWNER = address(0x1); +contract LivenessModule2 is ISemver { + /// @notice Configuration for a Safe's liveness module + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } /// @notice Mapping from Safe address to its configuration mapping(address => ModuleConfig) public safeConfigs; @@ -27,6 +30,51 @@ contract LivenessModule2 is ILivenessModule2 { /// @notice Mapping from Safe address to active challenge start time (0 if no challenge) mapping(address => uint256) public challengeStartTime; + /// @notice Reserved address used as the previous owner to the first owner in a Safe + address internal constant SENTINEL_OWNER = address(0x1); + + /// @notice Error for when module is not enabled for the Safe + error LivenessModule2_ModuleNotEnabled(); + + /// @notice Error for when Safe is not configured for this module + error LivenessModule2_ModuleNotConfigured(); + + /// @notice Error for when a challenge already exists + error LivenessModule2_ChallengeAlreadyExists(); + + /// @notice Error for when no challenge exists + error LivenessModule2_ChallengeDoesNotExist(); + + /// @notice Error for when trying to cancel a challenge after the response period has ended + error LivenessModule2_ResponsePeriodEnded(); + + /// @notice Error for when trying to execute ownership transfer while the response period is still active + error LivenessModule2_ResponsePeriodActive(); + + /// @notice Error for when caller is not authorized + error LivenessModule2_UnauthorizedCaller(); + + /// @notice Error for invalid parameters + error LivenessModule2_InvalidParameters(); + + /// @notice Error for when trying to clear configuration while module is still enabled + error LivenessModule2_ModuleStillEnabled(); + + /// @notice Emitted when a Safe enables the module + event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); + + /// @notice Emitted when a Safe disables the module + event ModuleDisabled(address indexed safe); + + /// @notice Emitted when a challenge is started + event ChallengeStarted(address indexed safe, uint256 challengeStartTime); + + /// @notice Emitted when a challenge is cancelled + event ChallengeCancelled(address indexed safe); + + /// @notice Emitted when ownership is transferred to the fallback owner + event ChallengeExecuted(address indexed safe, address fallbackOwner); + /// @notice Semantic version. /// @custom:semver 2.0.0 string public constant version = "2.0.0"; diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 1bf1465b7bd..e49ae7cab18 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -6,7 +6,6 @@ import { Enum } from "safe-contracts/common/Enum.sol"; import "test/safe-tools/SafeTestTools.sol"; import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; -import { ILivenessModule2 } from "interfaces/safe/ILivenessModule2.sol"; /// @title LivenessModule2_TestInit /// @notice Reusable test initialization for `LivenessModule2` tests. @@ -54,8 +53,8 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @notice Helper to enable the LivenessModule2 for a Safe function _enableModule(SafeInstance memory _safe, uint256 _period, address _fallback) internal { - ILivenessModule2.ModuleConfig memory config = - ILivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); + LivenessModule2.ModuleConfig memory config = + LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); SafeTestLib.execTransaction( _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.configure, (config)), Enum.Operation.Call ); @@ -145,26 +144,26 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { // Note: we don't call SafeTestLib.enableModule here // Now configure should revert because the module is not enabled at the Safe level - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(newSafe.safe)); livenessModule2.configure( - ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) ); } function test_configure_invalidParameters_reverts() external { // Test with zero period - vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); livenessModule2.configure( - ILivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) + LivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) ); // Test with zero address - vm.expectRevert(ILivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidParameters.selector); vm.prank(address(safeInstance.safe)); livenessModule2.configure( - ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) ); } @@ -188,7 +187,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { vm.prank(address(safeInstance.safe)); livenessModule2.configure( - ILivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD * 2, fallbackOwner: fallbackOwner }) + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD * 2, fallbackOwner: fallbackOwner }) ); // Verify challenge was cancelled @@ -222,7 +221,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { } function test_clear_notEnabled_reverts() external { - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeInstance.safe)); livenessModule2.clear(); } @@ -231,7 +230,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); // Try to clear while module is still enabled (should revert) - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleStillEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleStillEnabled.selector); vm.prank(address(safeInstance.safe)); livenessModule2.clear(); } @@ -259,7 +258,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function test_challenge_notFallbackOwner_reverts() external { address notFallback = makeAddr("notFallback"); - vm.expectRevert(ILivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); vm.prank(notFallback); livenessModule2.challenge(address(safeInstance.safe)); } @@ -267,7 +266,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { function test_challenge_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(fallbackOwner); livenessModule2.challenge(newSafe); } @@ -276,7 +275,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); - vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); } @@ -302,7 +301,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { ); // Try to challenge - should revert because module is disabled at Safe level - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(fallbackOwner); livenessModule2.challenge(address(disabledSafe.safe)); } @@ -355,7 +354,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); // Try to cancel - should fail as response period has expired - vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodEnded.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodEnded.selector); vm.prank(address(safeInstance.safe)); livenessModule2.respond(); } @@ -366,7 +365,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeInstance memory safeThatDidntEnable = _setupSafe(newKeys, THRESHOLD); // Note: we don't call SafeTestLib.enableModule here - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(safeThatDidntEnable.safe)); livenessModule2.respond(); } @@ -380,7 +379,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { SafeTestLib.enableModule(safeNotConfigured, address(livenessModule2)); // Note: we don't call _enableModule to configure it - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeNotConfigured.safe)); livenessModule2.respond(); } @@ -422,12 +421,12 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { address newSafe = makeAddr("newSafe"); - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); livenessModule2.changeOwnershipToFallback(newSafe); } function test_changeOwnershipToFallback_noChallenge_reverts() external { - vm.expectRevert(ILivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } @@ -437,7 +436,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI livenessModule2.challenge(address(safeInstance.safe)); // Try to execute before response period expires - vm.expectRevert(ILivenessModule2.LivenessModule2_ResponsePeriodActive.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } @@ -459,7 +458,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI ); // Try to execute ownership transfer - should revert because module is disabled at Safe level - vm.expectRevert(ILivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); } From e4669ac447cd802bc46d8aa629416b4f30ccbb9b Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:04:03 +0200 Subject: [PATCH 29/45] DRYness comments --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 45d1df67fe9..0029cce5e1e 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -136,7 +136,7 @@ contract LivenessModule2 is ISemver { /// @dev MUST never revert /// @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) { + function getChallengePeriodEnd(address _safe) public view returns (uint256) { uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { return 0; @@ -201,7 +201,7 @@ contract LivenessModule2 is ISemver { } // Check if response period has expired - if (block.timestamp >= startTime + config.livenessResponsePeriod) { + if (block.timestamp >= getChallengePeriodEnd(msg.sender)) { revert LivenessModule2_ResponsePeriodEnded(); } @@ -236,7 +236,7 @@ contract LivenessModule2 is ISemver { } // Check if response period has expired - if (block.timestamp < startTime + config.livenessResponsePeriod) { + if (block.timestamp < getChallengePeriodEnd(_safe)) { revert LivenessModule2_ResponsePeriodActive(); } From 405ae9cbfb25cc3412967bb7db8130fac7f6b6d0 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:05:09 +0200 Subject: [PATCH 30/45] move getter at the top --- .../src/safe/LivenessModule2.sol | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 0029cce5e1e..95ad136a74b 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -79,6 +79,20 @@ contract LivenessModule2 is ISemver { /// @custom:semver 2.0.0 string public constant version = "2.0.0"; + /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge for the given safe, or + /// 0 if not + /// @dev MUST never revert + /// @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) { + uint256 startTime = challengeStartTime[_safe]; + if (startTime == 0) { + return 0; + } + ModuleConfig storage config = safeConfigs[_safe]; + return startTime + config.livenessResponsePeriod; + } + /// @notice Configures the module for a Safe that has already enabled it /// @dev MUST only be callable by a Safe that has enabled this module /// @param _config The configuration parameters for the module @@ -131,20 +145,6 @@ contract LivenessModule2 is ISemver { emit ModuleDisabled(msg.sender); } - /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge for the given safe, or - /// 0 if not - /// @dev MUST never revert - /// @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) { - uint256 startTime = challengeStartTime[_safe]; - if (startTime == 0) { - return 0; - } - ModuleConfig storage config = safeConfigs[_safe]; - return startTime + config.livenessResponsePeriod; - } - /// @notice Challenges an enabled safe /// @dev MUST only be executable by fallback owner of the challenged safe /// @dev MUST revert if there is a challenge for the safe From 7b7923faffdfb3db05a099fceaee1f69789d9686 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:05:59 +0200 Subject: [PATCH 31/45] update comment --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 95ad136a74b..d04a1ecc941 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -16,7 +16,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// to the fallback owner. /// @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 enableModule() with parameters +/// 2. The Safe must then configure the module by calling configure() with parameters contract LivenessModule2 is ISemver { /// @notice Configuration for a Safe's liveness module struct ModuleConfig { From dc3d59fa1fd2c53ac6efc3b6de66ecec44e33d9b Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:09:29 +0200 Subject: [PATCH 32/45] rename event --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 4 ++-- packages/contracts-bedrock/test/safe/LivenessModule2.t.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index d04a1ecc941..5a0797ced91 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -73,7 +73,7 @@ contract LivenessModule2 is ISemver { event ChallengeCancelled(address indexed safe); /// @notice Emitted when ownership is transferred to the fallback owner - event ChallengeExecuted(address indexed safe, address fallbackOwner); + event ChallengeSucceeded(address indexed safe, address fallbackOwner); /// @notice Semantic version. /// @custom:semver 2.0.0 @@ -269,6 +269,6 @@ contract LivenessModule2 is ISemver { // Reset the challenge state to allow a new challenge delete challengeStartTime[_safe]; - emit ChallengeExecuted(_safe, config.fallbackOwner); + emit ChallengeSucceeded(_safe, config.fallbackOwner); } } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index e49ae7cab18..45b2047d8b6 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -17,7 +17,7 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { event ModuleDisabled(address indexed safe); event ChallengeStarted(address indexed safe, uint256 challengeStartTime); event ChallengeCancelled(address indexed safe); - event ChallengeExecuted(address indexed safe, address fallbackOwner); + event ChallengeSucceeded(address indexed safe, address fallbackOwner); uint256 constant INIT_TIME = 10; uint256 constant CHALLENGE_PERIOD = 7 days; @@ -403,7 +403,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Execute ownership transfer vm.expectEmit(true, true, true, true); - emit ChallengeExecuted(address(safeInstance.safe), fallbackOwner); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); From d0dc8a55253930db6176370ff9d11ee4da33e7cc Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:12:53 +0200 Subject: [PATCH 33/45] add _assertModuleConfigured --- .../src/safe/LivenessModule2.sol | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 5a0797ced91..4b5a1f9d594 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -126,11 +126,7 @@ contract LivenessModule2 is ISemver { /// @dev Note: Clearing the configuration also cancels any ongoing challenges function clear() external { // Check if the calling safe has configuration set - ModuleConfig storage config = safeConfigs[msg.sender]; - if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotConfigured(); - } - + _assertModuleConfigured(msg.sender); // Check that this module is NOT enabled on the calling Safe // This prevents clearing configuration while module is still enabled Safe safe = Safe(payable(msg.sender)); @@ -152,11 +148,7 @@ contract LivenessModule2 is ISemver { /// @dev MUST emit the ChallengeStarted event /// @param _safe The Safe to challenge function challenge(address _safe) external { - ModuleConfig storage config = safeConfigs[_safe]; - - if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotConfigured(); - } + _assertModuleConfigured(_safe); // Check that the module is still enabled on the target Safe Safe safe = Safe(payable(_safe)); @@ -164,7 +156,7 @@ contract LivenessModule2 is ISemver { revert LivenessModule2_ModuleNotEnabled(); } - if (msg.sender != config.fallbackOwner) { + if (msg.sender != safeConfigs[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -191,9 +183,7 @@ contract LivenessModule2 is ISemver { ModuleConfig storage config = safeConfigs[msg.sender]; // Check if the calling safe has configuration set - if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotConfigured(); - } + _assertModuleConfigured(msg.sender); uint256 startTime = challengeStartTime[msg.sender]; if (startTime == 0) { @@ -218,11 +208,7 @@ contract LivenessModule2 is ISemver { /// @dev MUST emit the ChallengeExecuted event /// @param _safe The Safe to transfer ownership of function changeOwnershipToFallback(address _safe) external { - ModuleConfig storage config = safeConfigs[_safe]; - - if (config.fallbackOwner == address(0)) { - revert LivenessModule2_ModuleNotConfigured(); - } + _assertModuleConfigured(_safe); // Check that the module is still enabled on the target Safe Safe safe = Safe(payable(_safe)); @@ -263,12 +249,19 @@ contract LivenessModule2 is ISemver { to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], config.fallbackOwner)) + data: abi.encodeCall(OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], safeConfigs[_safe].fallbackOwner)) }); // Reset the challenge state to allow a new challenge delete challengeStartTime[_safe]; - emit ChallengeSucceeded(_safe, config.fallbackOwner); + emit ChallengeSucceeded(_safe, safeConfigs[_safe].fallbackOwner); + } + + function _assertModuleConfigured(address _safe) internal view { + ModuleConfig storage config = safeConfigs[_safe]; + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotConfigured(); + } } } From 60a11a4dc578df50c92720de1a48af81b3d64dec Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:16:20 +0200 Subject: [PATCH 34/45] add _assertModuleEnabled --- .../src/safe/LivenessModule2.sol | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 4b5a1f9d594..71b46075ff2 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -151,10 +151,7 @@ contract LivenessModule2 is ISemver { _assertModuleConfigured(_safe); // Check that the module is still enabled on the target Safe - Safe safe = Safe(payable(_safe)); - if (!safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleNotEnabled(); - } + _assertModuleEnabled(_safe); if (msg.sender != safeConfigs[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); @@ -205,16 +202,13 @@ contract LivenessModule2 is ISemver { /// @dev MUST revert if the given safe hasn't enabled the module /// @dev MUST revert if there isn't a successful challenge for the given safe /// @dev MUST enable the module to start a new challenge - /// @dev MUST emit the ChallengeExecuted event + /// @dev MUST emit the ChallengeSucceeded event /// @param _safe The Safe to transfer ownership of function changeOwnershipToFallback(address _safe) external { _assertModuleConfigured(_safe); // Check that the module is still enabled on the target Safe - Safe safe = Safe(payable(_safe)); - if (!safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleNotEnabled(); - } + _assertModuleEnabled(_safe); uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { @@ -264,4 +258,11 @@ contract LivenessModule2 is ISemver { revert LivenessModule2_ModuleNotConfigured(); } } + + function _assertModuleEnabled(address _safe) internal view { + Safe safe = Safe(payable(_safe)); + if (!safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleNotEnabled(); + } + } } From a93ac08d2b71cd6116fffd7ad844ca9a6cc563fc Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:17:10 +0200 Subject: [PATCH 35/45] assert module enable fixes --- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 71b46075ff2..5998445a1fe 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -172,12 +172,7 @@ contract LivenessModule2 is ISemver { /// @dev MUST emit the ChallengeCancelled event function respond() external { // Check that this module is enabled on the calling Safe - Safe safe = Safe(payable(msg.sender)); - if (!safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleNotEnabled(); - } - - ModuleConfig storage config = safeConfigs[msg.sender]; + _assertModuleEnabled(msg.sender); // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); From 1c27cb67bd3974ac417b38c63975ac506347231f Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:24:11 +0200 Subject: [PATCH 36/45] _cancelChallenge internal function --- .../src/safe/LivenessModule2.sol | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 5998445a1fe..68993585530 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -112,10 +112,7 @@ contract LivenessModule2 is ISemver { // Clear any existing challenge when configuring/re-configuring // If a challenge exists, it MUST be canceled, including emitting the appropriate events - if (challengeStartTime[msg.sender] != 0) { - delete challengeStartTime[msg.sender]; - emit ChallengeCancelled(msg.sender); - } + _cancelChallenge(msg.sender); emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); } @@ -137,7 +134,7 @@ contract LivenessModule2 is ISemver { // Erase the configuration data for this safe delete safeConfigs[msg.sender]; // Also clear any active challenge - delete challengeStartTime[msg.sender]; + _cancelChallenge(msg.sender); emit ModuleDisabled(msg.sender); } @@ -187,8 +184,7 @@ contract LivenessModule2 is ISemver { revert LivenessModule2_ResponsePeriodEnded(); } - delete challengeStartTime[msg.sender]; - emit ChallengeCancelled(msg.sender); + _cancelChallenge(msg.sender); } /// @notice With a successful challenge, removes all current owners from an enabled safe, appoints fallback as its @@ -260,4 +256,13 @@ contract LivenessModule2 is ISemver { revert LivenessModule2_ModuleNotEnabled(); } } + + /// @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 { + if (challengeStartTime[_safe] != 0) { + delete challengeStartTime[_safe]; + emit ChallengeCancelled(_safe); + } + } } From 44c3ada996c9b9bd5455e7bb81522b7d1a6ead2b Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:35:49 +0200 Subject: [PATCH 37/45] fix comments --- .../src/safe/LivenessModule2.sol | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 68993585530..3f46e834ca0 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -10,13 +10,13 @@ import { OwnerManager } from "safe-contracts/base/OwnerManager.sol"; import { ISemver } from "interfaces/universal/ISemver.sol"; /// @title LivenessModule2 -/// @notice This module allows for challenge-based ownership transfer to a fallback owner +/// @notice This module allows challenge-based ownership transfer to a fallback owner /// when the Safe becomes unresponsive. The fallback owner can initiate a challenge, /// and if the Safe doesn't respond within the challenge period, ownership transfers /// to the fallback owner. /// @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 parameters +/// 2. The Safe must then configure the module by calling configure() with params contract LivenessModule2 is ISemver { /// @notice Configuration for a Safe's liveness module struct ModuleConfig { @@ -45,10 +45,10 @@ contract LivenessModule2 is ISemver { /// @notice Error for when no challenge exists error LivenessModule2_ChallengeDoesNotExist(); - /// @notice Error for when trying to cancel a challenge after the response period has ended + /// @notice Error for when trying to cancel a challenge after response period has ended error LivenessModule2_ResponsePeriodEnded(); - /// @notice Error for when trying to execute ownership transfer while the response period is still active + /// @notice Error for when trying to execute ownership transfer while response period is active error LivenessModule2_ResponsePeriodActive(); /// @notice Error for when caller is not authorized @@ -57,7 +57,7 @@ contract LivenessModule2 is ISemver { /// @notice Error for invalid parameters error LivenessModule2_InvalidParameters(); - /// @notice Error for when trying to clear configuration while module is still enabled + /// @notice Error for when trying to clear configuration while module is enabled error LivenessModule2_ModuleStillEnabled(); /// @notice Emitted when a Safe enables the module @@ -79,7 +79,7 @@ contract LivenessModule2 is ISemver { /// @custom:semver 2.0.0 string public constant version = "2.0.0"; - /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge for the given safe, or + /// @notice Returns challenge_start_time + liveness_response_period if challenge exists, or /// 0 if not /// @dev MUST never revert /// @param _safe The Safe address to query @@ -97,11 +97,17 @@ contract LivenessModule2 is ISemver { /// @dev MUST only be callable by a Safe that has enabled this module /// @param _config The configuration parameters for the module function configure(ModuleConfig memory _config) external { + // Validate configuration parameters to ensure module can function properly. + // livenessResponsePeriod must be > 0 to allow time for Safe owners to respond. + // fallbackOwner must not be zero address to have a valid ownership recipient. if (_config.livenessResponsePeriod == 0 || _config.fallbackOwner == address(0)) { revert LivenessModule2_InvalidParameters(); } - // Check that this module is enabled on the calling Safe + // Check that this module is enabled on the calling Safe. + // This prevents Safes from configuring themselves for a module they haven't enabled, + // which would create orphaned configuration data. The Safe must first call + // enableModule() before configure() to ensure proper two-step initialization. Safe safe = Safe(payable(msg.sender)); if (!safe.isModuleEnabled(address(this))) { revert LivenessModule2_ModuleNotEnabled(); @@ -110,8 +116,11 @@ contract LivenessModule2 is ISemver { // Store the configuration for this safe safeConfigs[msg.sender] = _config; - // Clear any existing challenge when configuring/re-configuring - // If a challenge exists, it MUST be canceled, including emitting the appropriate events + // Clear any existing challenge when configuring/re-configuring. + // This is necessary because changing the configuration (especially livenessResponsePeriod) + // would invalidate any ongoing challenge timing, creating inconsistent state. + // For example, if a challenge was started with a 7-day period and we reconfigure to + // 1 day, the challenge timing becomes ambiguous. Canceling ensures clean state. _cancelChallenge(msg.sender); emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); @@ -119,7 +128,7 @@ contract LivenessModule2 is ISemver { /// @notice Clears the module configuration for a Safe /// @dev MUST only be executable by a Safe that has DISABLED this module first - /// @dev MUST erase the existing liveness_response_period and fallback_owner data related to the calling safe + /// @dev MUST erase existing liveness_response_period and fallback_owner data /// @dev Note: Clearing the configuration also cancels any ongoing challenges function clear() external { // Check if the calling safe has configuration set @@ -165,7 +174,7 @@ contract LivenessModule2 is ISemver { /// @notice Responds to a challenge for an enabled safe, canceling it /// @dev MUST only be executable by an enabled safe /// @dev MUST revert if there isn't a challenge for the calling safe - /// @dev MUST revert if there is a challenge for the calling safe but the response period has expired + /// @dev MUST revert if challenge exists but response period has expired /// @dev MUST emit the ChallengeCancelled event function respond() external { // Check that this module is enabled on the calling Safe @@ -187,7 +196,7 @@ contract LivenessModule2 is ISemver { _cancelChallenge(msg.sender); } - /// @notice With a successful challenge, removes all current owners from an enabled safe, appoints fallback as its + /// @notice With successful challenge, removes all current owners from enabled safe, /// sole owner, and sets its quorum to 1 /// @dev MUST be executable by anyone /// @dev MUST revert if the given safe hasn't enabled the module @@ -196,17 +205,20 @@ contract LivenessModule2 is ISemver { /// @dev MUST emit the ChallengeSucceeded event /// @param _safe The Safe to transfer ownership of function changeOwnershipToFallback(address _safe) external { + // Ensure Safe is configured with this module to prevent unauthorized execution _assertModuleConfigured(_safe); - // Check that the module is still enabled on the target Safe + // Verify module is still enabled to ensure Safe hasn't disabled it mid-challenge _assertModuleEnabled(_safe); + // Verify active challenge exists - without challenge, ownership transfer not allowed uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); } - // Check if response period has expired + // Ensure response period has fully expired before allowing ownership transfer. + // This gives Safe owners full configured time to demonstrate liveness. if (block.timestamp < getChallengePeriodEnd(_safe)) { revert LivenessModule2_ResponsePeriodActive(); } @@ -218,7 +230,7 @@ contract LivenessModule2 is ISemver { // Remove all owners after the first one while (owners.length > 1) { - // removeOwner automatically updates the threshold, so we don't need to do it manually + // removeOwner automatically updates threshold, no manual update needed targetSafe.execTransactionFromModule({ to: _safe, value: 0, From 3fb263977c1879281aa3b49c803aa17c1b4d1768 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:36:03 +0200 Subject: [PATCH 38/45] more comments --- 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 3f46e834ca0..a119470796e 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -27,10 +27,10 @@ contract LivenessModule2 is ISemver { /// @notice Mapping from Safe address to its configuration mapping(address => ModuleConfig) public safeConfigs; - /// @notice Mapping from Safe address to active challenge start time (0 if no challenge) + /// @notice Mapping from Safe address to active challenge start time (0 if none) mapping(address => uint256) public challengeStartTime; - /// @notice Reserved address used as the previous owner to the first owner in a Safe + /// @notice Reserved address used as previous owner to the first owner in a Safe address internal constant SENTINEL_OWNER = address(0x1); /// @notice Error for when module is not enabled for the Safe From e177a22c008644d4cc850120571c04e771e78f2e Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 15:41:37 +0200 Subject: [PATCH 39/45] split configure errors --- .../snapshots/abi/LivenessModule2.json | 23 +++++++++++-------- .../snapshots/semver-lock.json | 4 ++-- .../src/safe/LivenessModule2.sol | 16 +++++++++---- .../test/safe/LivenessModule2.t.sol | 8 ++++--- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 7fd1a9995ff..b1357d75ffa 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -163,12 +163,12 @@ }, { "indexed": false, - "internalType": "address", - "name": "fallbackOwner", - "type": "address" + "internalType": "uint256", + "name": "challengeStartTime", + "type": "uint256" } ], - "name": "ChallengeExecuted", + "name": "ChallengeStarted", "type": "event" }, { @@ -182,12 +182,12 @@ }, { "indexed": false, - "internalType": "uint256", - "name": "challengeStartTime", - "type": "uint256" + "internalType": "address", + "name": "fallbackOwner", + "type": "address" } ], - "name": "ChallengeStarted", + "name": "ChallengeSucceeded", "type": "event" }, { @@ -240,7 +240,12 @@ }, { "inputs": [], - "name": "LivenessModule2_InvalidParameters", + "name": "LivenessModule2_InvalidFallbackOwner", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidResponsePeriod", "type": "error" }, { diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 21909019685..5fafadb9b11 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x34909faff5fd1ad321f86825ceda4542a9708a6d36ba35f356577a3df10a9f2c", - "sourceCodeHash": "0xa6f0568c42b23f4afa49c3df77de2e661db93e6e413d01a5fc543205248413c2" + "initCodeHash": "0x15a8c4cd697cedfc44823bf249be4e6b1ab72f6e6e664fe14fd591cd483961c8", + "sourceCodeHash": "0xd96ee4dd523dde71c51fdadc45dd6f3b72b90df3e358f5c7c07bbcd59f6537f3" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index a119470796e..e4d077c91f9 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -54,8 +54,11 @@ contract LivenessModule2 is ISemver { /// @notice Error for when caller is not authorized error LivenessModule2_UnauthorizedCaller(); - /// @notice Error for invalid parameters - error LivenessModule2_InvalidParameters(); + /// @notice Error for invalid response period + error LivenessModule2_InvalidResponsePeriod(); + + /// @notice Error for invalid fallback owner + error LivenessModule2_InvalidFallbackOwner(); /// @notice Error for when trying to clear configuration while module is enabled error LivenessModule2_ModuleStillEnabled(); @@ -99,9 +102,12 @@ contract LivenessModule2 is ISemver { function configure(ModuleConfig memory _config) external { // 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) { + revert LivenessModule2_InvalidResponsePeriod(); + } // fallbackOwner must not be zero address to have a valid ownership recipient. - if (_config.livenessResponsePeriod == 0 || _config.fallbackOwner == address(0)) { - revert LivenessModule2_InvalidParameters(); + if (_config.fallbackOwner == address(0)) { + revert LivenessModule2_InvalidFallbackOwner(); } // Check that this module is enabled on the calling Safe. @@ -119,7 +125,7 @@ contract LivenessModule2 is ISemver { // Clear any existing challenge when configuring/re-configuring. // This is necessary because changing the configuration (especially livenessResponsePeriod) // would invalidate any ongoing challenge timing, creating inconsistent state. - // For example, if a challenge was started with a 7-day period and we reconfigure to + // For example, if a challenge was started with a 7-day period and we reconfigure to // 1 day, the challenge timing becomes ambiguous. Canceling ensures clean state. _cancelChallenge(msg.sender); diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 45b2047d8b6..05d04ea8146 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -151,16 +151,18 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { ); } - function test_configure_invalidParameters_reverts() external { + function test_configure_invalidResponsePeriod_reverts() external { // Test with zero period - vm.expectRevert(LivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidResponsePeriod.selector); vm.prank(address(safeInstance.safe)); livenessModule2.configure( LivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) ); + } + function test_configure_invalidFallbackOwner_reverts() external { // Test with zero address - vm.expectRevert(LivenessModule2.LivenessModule2_InvalidParameters.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidFallbackOwner.selector); vm.prank(address(safeInstance.safe)); livenessModule2.configure( LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) From a8a54cd0de6527e08029829d1a614e2e8c26209a Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 8 Sep 2025 16:00:11 +0200 Subject: [PATCH 40/45] owner transfer invariant --- .../contracts-bedrock/snapshots/abi/LivenessModule2.json | 5 +++++ packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index b1357d75ffa..59053a989ca 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -263,6 +263,11 @@ "name": "LivenessModule2_ModuleStillEnabled", "type": "error" }, + { + "inputs": [], + "name": "LivenessModule2_OwnershipTransferFailed", + "type": "error" + }, { "inputs": [], "name": "LivenessModule2_ResponsePeriodActive", diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 5fafadb9b11..7e81a15a84e 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x15a8c4cd697cedfc44823bf249be4e6b1ab72f6e6e664fe14fd591cd483961c8", - "sourceCodeHash": "0xd96ee4dd523dde71c51fdadc45dd6f3b72b90df3e358f5c7c07bbcd59f6537f3" + "initCodeHash": "0x3c8d17f05158f1dadc51e7fcc454d700e9bacdd231d6d1ec81bda0bbc5bd4150", + "sourceCodeHash": "0xd3a2c64c09e595a17b388dfef0c90424f2bfe919d25460c114b8d16612407fa5" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index e4d077c91f9..68108f6f54c 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -63,6 +63,9 @@ contract LivenessModule2 is ISemver { /// @notice Error for when trying to clear configuration while module is enabled error LivenessModule2_ModuleStillEnabled(); + /// @notice Error for when ownership transfer verification fails + error LivenessModule2_OwnershipTransferFailed(); + /// @notice Emitted when a Safe enables the module event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); @@ -255,6 +258,12 @@ contract LivenessModule2 is ISemver { data: abi.encodeCall(OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], safeConfigs[_safe].fallbackOwner)) }); + // Sanity check: verify the fallback owner is now the only owner + address[] memory finalOwners = targetSafe.getOwners(); + if (finalOwners.length != 1 || finalOwners[0] != safeConfigs[_safe].fallbackOwner) { + revert LivenessModule2_OwnershipTransferFailed(); + } + // Reset the challenge state to allow a new challenge delete challengeStartTime[_safe]; From e03a5d7dd728851d60cf929827f0cbec5f1803ee Mon Sep 17 00:00:00 2001 From: JosepBove Date: Mon, 15 Sep 2025 15:01:30 +0200 Subject: [PATCH 41/45] address comments --- .../interfaces/safe/ILivenessModule2.sol | 7 ++- .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 62 +++++++------------ .../test/safe/LivenessModule2.t.sol | 25 ++++++-- 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 44f6b12fa82..5cb99dd5909 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -6,6 +6,11 @@ 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 { + /// @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 @@ -22,7 +27,7 @@ interface ILivenessModule2 is ISemver { /// @notice Configures the module for a Safe that has already enabled it /// @param _config The configuration parameters for the module - ///function configure(ModuleConfig memory _config) external; + function configure(ModuleConfig memory _config) external; /// @notice Clears the module configuration for a Safe function clear() external; diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 7e81a15a84e..ed513e3a68a 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x3c8d17f05158f1dadc51e7fcc454d700e9bacdd231d6d1ec81bda0bbc5bd4150", - "sourceCodeHash": "0xd3a2c64c09e595a17b388dfef0c90424f2bfe919d25460c114b8d16612407fa5" + "initCodeHash": "0x18a8fcd72d8e50f62e7febc16ab9ef04ef3075f7728963a4f23f40f74b45e1ef", + "sourceCodeHash": "0x25589aab4808dc3e4c97536aa196199a61e6fe3395f7ac206f7f009411d7fa42" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 68108f6f54c..298944ef5cf 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -87,7 +87,6 @@ contract LivenessModule2 is ISemver { /// @notice Returns challenge_start_time + liveness_response_period if challenge exists, or /// 0 if not - /// @dev MUST never revert /// @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) { @@ -100,7 +99,6 @@ contract LivenessModule2 is ISemver { } /// @notice Configures the module for a Safe that has already enabled it - /// @dev MUST only be callable by a Safe that has enabled this module /// @param _config The configuration parameters for the module function configure(ModuleConfig memory _config) external { // Validate configuration parameters to ensure module can function properly. @@ -114,13 +112,7 @@ contract LivenessModule2 is ISemver { } // Check that this module is enabled on the calling Safe. - // This prevents Safes from configuring themselves for a module they haven't enabled, - // which would create orphaned configuration data. The Safe must first call - // enableModule() before configure() to ensure proper two-step initialization. - Safe safe = Safe(payable(msg.sender)); - if (!safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleNotEnabled(); - } + _assertModuleEnabled(msg.sender); // Store the configuration for this safe safeConfigs[msg.sender] = _config; @@ -136,18 +128,13 @@ contract LivenessModule2 is ISemver { } /// @notice Clears the module configuration for a Safe - /// @dev MUST only be executable by a Safe that has DISABLED this module first - /// @dev MUST erase existing liveness_response_period and fallback_owner data /// @dev Note: Clearing the configuration also cancels any ongoing challenges function clear() external { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); // Check that this module is NOT enabled on the calling Safe // This prevents clearing configuration while module is still enabled - Safe safe = Safe(payable(msg.sender)); - if (safe.isModuleEnabled(address(this))) { - revert LivenessModule2_ModuleStillEnabled(); - } + _assertModuleNotEnabled(msg.sender); // Erase the configuration data for this safe delete safeConfigs[msg.sender]; @@ -157,10 +144,6 @@ contract LivenessModule2 is ISemver { } /// @notice Challenges an enabled safe - /// @dev MUST only be executable by fallback owner of the challenged safe - /// @dev MUST revert if there is a challenge for the safe - /// @dev MUST set challenge_start_time to the current block time - /// @dev MUST emit the ChallengeStarted event /// @param _safe The Safe to challenge function challenge(address _safe) external { _assertModuleConfigured(_safe); @@ -181,10 +164,6 @@ contract LivenessModule2 is ISemver { } /// @notice Responds to a challenge for an enabled safe, canceling it - /// @dev MUST only be executable by an enabled safe - /// @dev MUST revert if there isn't a challenge for the calling safe - /// @dev MUST revert if challenge exists but response period has expired - /// @dev MUST emit the ChallengeCancelled event function respond() external { // Check that this module is enabled on the calling Safe _assertModuleEnabled(msg.sender); @@ -197,21 +176,13 @@ contract LivenessModule2 is ISemver { revert LivenessModule2_ChallengeDoesNotExist(); } - // Check if response period has expired - if (block.timestamp >= getChallengePeriodEnd(msg.sender)) { - revert LivenessModule2_ResponsePeriodEnded(); - } - + // 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); } /// @notice With successful challenge, removes all current owners from enabled safe, - /// sole owner, and sets its quorum to 1 - /// @dev MUST be executable by anyone - /// @dev MUST revert if the given safe hasn't enabled the module - /// @dev MUST revert if there isn't a successful challenge for the given safe - /// @dev MUST enable the module to start a new challenge - /// @dev MUST emit the ChallengeSucceeded event + /// appoints fallback as sole owner, and sets its quorum to 1 /// @param _safe The Safe to transfer ownership of function changeOwnershipToFallback(address _safe) external { // Ensure Safe is configured with this module to prevent unauthorized execution @@ -220,6 +191,11 @@ contract LivenessModule2 is ISemver { // Verify module is still enabled to ensure Safe hasn't disabled it mid-challenge _assertModuleEnabled(_safe); + // Only fallback owner can execute ownership transfer (per specs update) + if (msg.sender != safeConfigs[_safe].fallbackOwner) { + revert LivenessModule2_UnauthorizedCaller(); + } + // Verify active challenge exists - without challenge, ownership transfer not allowed uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { @@ -238,15 +214,15 @@ contract LivenessModule2 is ISemver { address[] memory owners = targetSafe.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) { - // removeOwner automatically updates threshold, no manual update needed targetSafe.execTransactionFromModule({ to: _safe, value: 0, operation: Enum.Operation.Call, data: abi.encodeCall(OwnerManager.removeOwner, (SENTINEL_OWNER, owners[0], 1)) }); - // Get updated owners list after removal owners = targetSafe.getOwners(); } @@ -287,9 +263,17 @@ contract LivenessModule2 is ISemver { /// @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 { - if (challengeStartTime[_safe] != 0) { - delete challengeStartTime[_safe]; - emit ChallengeCancelled(_safe); + // Early return if no challenge exists + if (challengeStartTime[_safe] == 0) return; + + delete challengeStartTime[_safe]; + emit ChallengeCancelled(_safe); + } + + function _assertModuleNotEnabled(address _safe) internal view { + Safe safe = Safe(payable(_safe)); + if (safe.isModuleEnabled(address(this))) { + revert LivenessModule2_ModuleStillEnabled(); } } } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 05d04ea8146..27b8f164386 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -347,7 +347,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { assertFalse(success, "Should fail to cancel non-existent challenge"); } - function test_respond_afterResponsePeriod_reverts() external { + function test_respond_afterResponsePeriod_succeeds() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); @@ -355,10 +355,14 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - // Try to cancel - should fail as response period has expired - vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodEnded.selector); + // Should be able to respond even after response period (per new specs) + vm.expectEmit(true, true, true, true); + emit ChallengeCancelled(address(safeInstance.safe)); vm.prank(address(safeInstance.safe)); livenessModule2.respond(); + + // Verify challenge was cancelled + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } function test_respond_moduleNotEnabled_reverts() external { @@ -407,6 +411,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI vm.expectEmit(true, true, true, true); emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + vm.prank(fallbackOwner); livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); // Verify ownership changed @@ -423,11 +428,13 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI 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)); } @@ -438,6 +445,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI 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)); } @@ -460,11 +468,12 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI ); // 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_canBeCalledByAnyone_succeeds() external { + function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { // Start a challenge vm.prank(fallbackOwner); livenessModule2.challenge(address(safeInstance.safe)); @@ -472,9 +481,14 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI // Warp past challenge period vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); - // Execute from random address + // 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 @@ -489,6 +503,7 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI 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) From 01eade257e5e6660b2f9d42a907cc4a5b0c82c64 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 16 Sep 2025 11:26:04 +0200 Subject: [PATCH 42/45] address kelvin's feedback --- .../src/safe/LivenessModule2.sol | 109 +++++++++++------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 298944ef5cf..742a08cc964 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -18,67 +18,78 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// 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 { - /// @notice Configuration for a Safe's liveness module + /// @notice Configuration for a Safe's liveness module. + /// @custom:field livenessResponsePeriod The duration in seconds that Safe owners have to respond to a challenge. + /// @custom:field fallbackOwner The address that can initiate challenges and claim ownership if the Safe is unresponsive. struct ModuleConfig { uint256 livenessResponsePeriod; address fallbackOwner; } - /// @notice Mapping from Safe address to its configuration + /// @notice Mapping from Safe address to its configuration. mapping(address => ModuleConfig) public safeConfigs; - /// @notice Mapping from Safe address to active challenge start time (0 if none) + /// @notice Mapping from Safe address to active challenge start time (0 if none). mapping(address => uint256) public challengeStartTime; - /// @notice Reserved address used as previous owner to the first owner in a Safe + /// @notice Reserved address used as previous owner to the first owner in a Safe. address internal constant SENTINEL_OWNER = address(0x1); - /// @notice Error for when module is not enabled for the Safe + /// @notice Error for when module is not enabled for the Safe. error LivenessModule2_ModuleNotEnabled(); - /// @notice Error for when Safe is not configured for this module + /// @notice Error for when Safe is not configured for this module. error LivenessModule2_ModuleNotConfigured(); - /// @notice Error for when a challenge already exists + /// @notice Error for when a challenge already exists. error LivenessModule2_ChallengeAlreadyExists(); - /// @notice Error for when no challenge exists + /// @notice Error for when no challenge exists. error LivenessModule2_ChallengeDoesNotExist(); - /// @notice Error for when trying to cancel a challenge after response period has ended + /// @notice Error for when trying to cancel a challenge after response period has ended. error LivenessModule2_ResponsePeriodEnded(); - /// @notice Error for when trying to execute ownership transfer while response period is active + /// @notice Error for when trying to execute ownership transfer while response period is active. error LivenessModule2_ResponsePeriodActive(); - /// @notice Error for when caller is not authorized + /// @notice Error for when caller is not authorized. error LivenessModule2_UnauthorizedCaller(); - /// @notice Error for invalid response period + /// @notice Error for invalid response period. error LivenessModule2_InvalidResponsePeriod(); - /// @notice Error for invalid fallback owner + /// @notice Error for invalid fallback owner. error LivenessModule2_InvalidFallbackOwner(); - /// @notice Error for when trying to clear configuration while module is enabled + /// @notice Error for when trying to clear configuration while module is enabled. error LivenessModule2_ModuleStillEnabled(); - /// @notice Error for when ownership transfer verification fails + /// @notice Error for when ownership transfer verification fails. error LivenessModule2_OwnershipTransferFailed(); - /// @notice Emitted when a Safe enables the module + /// @notice Emitted when a Safe enables the module. + /// @param safe The Safe address that enabled the module. + /// @param livenessResponsePeriod The duration in seconds that Safe owners have to respond to a challenge. + /// @param fallbackOwner The address that can initiate challenges and claim ownership if the Safe is unresponsive. event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); - /// @notice Emitted when a Safe disables the module + /// @notice Emitted when a Safe disables the module. + /// @param safe The Safe address that disabled the module. event ModuleDisabled(address indexed safe); - /// @notice Emitted when a challenge is started + /// @notice Emitted when a challenge is started. + /// @param safe The Safe address that started the challenge. + /// @param challengeStartTime The timestamp when the challenge started. event ChallengeStarted(address indexed safe, uint256 challengeStartTime); - /// @notice Emitted when a challenge is cancelled + /// @notice Emitted when a challenge is cancelled. + /// @param safe The Safe address that cancelled the challenge. event ChallengeCancelled(address indexed safe); - /// @notice Emitted when ownership is transferred to the fallback owner + /// @notice Emitted when ownership is transferred to the fallback owner. + /// @param safe The Safe address that succeeded the challenge. + /// @param fallbackOwner The address that claimed ownership if the Safe is unresponsive. event ChallengeSucceeded(address indexed safe, address fallbackOwner); /// @notice Semantic version. @@ -86,9 +97,9 @@ contract LivenessModule2 is ISemver { string public constant version = "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 - /// @return The challenge end timestamp, or 0 if no challenge + /// 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) { uint256 startTime = challengeStartTime[_safe]; if (startTime == 0) { @@ -98,8 +109,8 @@ contract LivenessModule2 is ISemver { return startTime + config.livenessResponsePeriod; } - /// @notice Configures the module for a Safe that has already enabled it - /// @param _config The configuration parameters for the module + /// @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 configure(ModuleConfig memory _config) external { // Validate configuration parameters to ensure module can function properly. // livenessResponsePeriod must be > 0 to allow time for Safe owners to respond. @@ -122,13 +133,23 @@ contract LivenessModule2 is ISemver { // would invalidate any ongoing challenge timing, creating inconsistent state. // For example, if a challenge was started with a 7-day period and we reconfigure to // 1 day, the challenge timing becomes ambiguous. Canceling ensures clean state. + // 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); emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); } - /// @notice Clears the module configuration for a Safe - /// @dev Note: Clearing the configuration also cancels any ongoing challenges + /// @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 + /// the LivenessModule2 configuration. Typical usage pattern: + /// 1. Safe disables the module via ModuleManager.disableModule(). + /// 2. Safe calls this clear() function to remove stored configuration. + /// 3. If Safe later re-enables the module, it must call configure() again. + /// Never calling clear() after disabling keeps configuration data persistent + /// for potential future re-enabling. function clear() external { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); @@ -143,12 +164,12 @@ contract LivenessModule2 is ISemver { emit ModuleDisabled(msg.sender); } - /// @notice Challenges an enabled safe - /// @param _safe The Safe to challenge + /// @notice Challenges an enabled safe. + /// @param _safe The Safe address to challenge. function challenge(address _safe) external { _assertModuleConfigured(_safe); - // Check that the module is still enabled on the target Safe + // Check that the module is still enabled on the target Safe. _assertModuleEnabled(_safe); if (msg.sender != safeConfigs[_safe].fallbackOwner) { @@ -163,12 +184,12 @@ contract LivenessModule2 is ISemver { emit ChallengeStarted(_safe, block.timestamp); } - /// @notice Responds to a challenge for an enabled safe, canceling it + /// @notice Responds to a challenge for an enabled safe, canceling it. function respond() external { - // Check that this module is enabled on the calling Safe + // Check that this module is enabled on the calling Safe. _assertModuleEnabled(msg.sender); - // Check if the calling safe has configuration set + // Check if the calling safe has configuration set. _assertModuleConfigured(msg.sender); uint256 startTime = challengeStartTime[msg.sender]; @@ -182,13 +203,17 @@ contract LivenessModule2 is ISemver { } /// @notice With successful challenge, removes all current owners from enabled safe, - /// appoints fallback as sole owner, and sets its quorum to 1 - /// @param _safe The Safe to transfer ownership of + /// appoints fallback as sole owner, and sets its quorum to 1. + /// @dev Note: After ownership transfer, the fallback owner becomes the sole owner + /// and is also still configured as the fallback owner. This means the + /// 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 { - // Ensure Safe is configured with this module to prevent unauthorized execution + // Ensure Safe is configured with this module to prevent unauthorized execution. _assertModuleConfigured(_safe); - // Verify module is still enabled to ensure Safe hasn't disabled it mid-challenge + // Verify module is still enabled to ensure Safe hasn't disabled it mid-challenge. _assertModuleEnabled(_safe); // Only fallback owner can execute ownership transfer (per specs update) @@ -246,6 +271,8 @@ contract LivenessModule2 is ISemver { emit ChallengeSucceeded(_safe, safeConfigs[_safe].fallbackOwner); } + /// @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 = safeConfigs[_safe]; if (config.fallbackOwner == address(0)) { @@ -253,6 +280,8 @@ contract LivenessModule2 is ISemver { } } + /// @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))) { @@ -260,8 +289,8 @@ contract LivenessModule2 is ISemver { } } - /// @notice Internal function to cancel a challenge and emit the appropriate event - /// @param _safe The Safe address for which to cancel the challenge + /// @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 { // Early return if no challenge exists if (challengeStartTime[_safe] == 0) return; @@ -270,6 +299,8 @@ contract LivenessModule2 is ISemver { emit ChallengeCancelled(_safe); } + /// @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))) { From 58184724925b612b5a6af1f113619b70616ff7dd Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 16 Sep 2025 16:52:20 +0200 Subject: [PATCH 43/45] lint --- packages/contracts-bedrock/snapshots/semver-lock.json | 2 +- packages/contracts-bedrock/src/safe/LivenessModule2.sol | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index ed513e3a68a..c2e1b0b4207 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -201,7 +201,7 @@ }, "src/safe/LivenessModule2.sol:LivenessModule2": { "initCodeHash": "0x18a8fcd72d8e50f62e7febc16ab9ef04ef3075f7728963a4f23f40f74b45e1ef", - "sourceCodeHash": "0x25589aab4808dc3e4c97536aa196199a61e6fe3395f7ac206f7f009411d7fa42" + "sourceCodeHash": "0x34cde35b3d99acaea06fc7be6b4b7b902bd5429f3cccf8ad607054fabeb40cd1" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 742a08cc964..13f9cf79402 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -20,7 +20,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; contract LivenessModule2 is ISemver { /// @notice Configuration for a Safe's liveness module. /// @custom:field livenessResponsePeriod The duration in seconds that Safe owners have to respond to a challenge. - /// @custom:field fallbackOwner The address that can initiate challenges and claim ownership if the Safe is unresponsive. + /// @custom:field fallbackOwner The address that can initiate challenges and claim ownership if the Safe is + /// unresponsive. struct ModuleConfig { uint256 livenessResponsePeriod; address fallbackOwner; From e8f4b89dcd277c4bdecf8ac142fdbfd7c2110cae Mon Sep 17 00:00:00 2001 From: JosepBove Date: Tue, 16 Sep 2025 18:18:22 +0200 Subject: [PATCH 44/45] more comments --- .../snapshots/semver-lock.json | 4 +- .../src/safe/LivenessModule2.sol | 34 +++++++++++----- .../test/safe/LivenessModule2.t.sol | 40 ++++++++++++++----- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index c2e1b0b4207..a26272a76b8 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -200,8 +200,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x18a8fcd72d8e50f62e7febc16ab9ef04ef3075f7728963a4f23f40f74b45e1ef", - "sourceCodeHash": "0x34cde35b3d99acaea06fc7be6b4b7b902bd5429f3cccf8ad607054fabeb40cd1" + "initCodeHash": "0x7fbf205034d4507d37713d5a0a8018086a5c4855f1ce2fe1f9a72958b6b0bf15", + "sourceCodeHash": "0x9c455da81a4a7bedbfb304621bee819235961aadbd2313ae5bab1bcf6a1cff2b" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 13f9cf79402..4207a434c49 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -19,9 +19,10 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// 2. The Safe must then configure the module by calling configure() with params contract LivenessModule2 is ISemver { /// @notice Configuration for a Safe's liveness module. - /// @custom:field livenessResponsePeriod The duration in seconds that Safe owners have to respond to a challenge. - /// @custom:field fallbackOwner The address that can initiate challenges and claim ownership if the Safe is - /// unresponsive. + /// @custom:field livenessResponsePeriod The duration in seconds that Safe owners have to + /// respond to a challenge. + /// @custom:field fallbackOwner The address that can initiate challenges and claim + /// ownership if the Safe is unresponsive. struct ModuleConfig { uint256 livenessResponsePeriod; address fallbackOwner; @@ -51,7 +52,8 @@ contract LivenessModule2 is ISemver { /// @notice Error for when trying to cancel a challenge after response period has ended. error LivenessModule2_ResponsePeriodEnded(); - /// @notice Error for when trying to execute ownership transfer while response period is active. + /// @notice Error for when trying to execute ownership transfer while response period is + /// active. error LivenessModule2_ResponsePeriodActive(); /// @notice Error for when caller is not authorized. @@ -71,8 +73,10 @@ contract LivenessModule2 is ISemver { /// @notice Emitted when a Safe enables the module. /// @param safe The Safe address that enabled the module. - /// @param livenessResponsePeriod The duration in seconds that Safe owners have to respond to a challenge. - /// @param fallbackOwner The address that can initiate challenges and claim ownership if the Safe is unresponsive. + /// @param livenessResponsePeriod The duration in seconds that Safe owners have to + /// respond to a challenge. + /// @param fallbackOwner The address that can initiate challenges and claim ownership if + /// the Safe is unresponsive. event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); /// @notice Emitted when a Safe disables the module. @@ -111,7 +115,8 @@ contract LivenessModule2 is ISemver { } /// @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. + /// @param _config The configuration parameters for the module containing the response + /// period and fallback owner. function configure(ModuleConfig memory _config) external { // Validate configuration parameters to ensure module can function properly. // livenessResponsePeriod must be > 0 to allow time for Safe owners to respond. @@ -130,7 +135,8 @@ contract LivenessModule2 is ISemver { safeConfigs[msg.sender] = _config; // Clear any existing challenge when configuring/re-configuring. - // This is necessary because changing the configuration (especially livenessResponsePeriod) + // This is necessary because changing the configuration (especially + // livenessResponsePeriod) // would invalidate any ongoing challenge timing, creating inconsistent state. // For example, if a challenge was started with a 7-day period and we reconfigure to // 1 day, the challenge timing becomes ambiguous. Canceling ensures clean state. @@ -154,6 +160,7 @@ contract LivenessModule2 is ISemver { function clear() external { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); + // Check that this module is NOT enabled on the calling Safe // This prevents clearing configuration while module is still enabled _assertModuleNotEnabled(msg.sender); @@ -168,31 +175,36 @@ contract LivenessModule2 is ISemver { /// @notice Challenges an enabled safe. /// @param _safe The Safe address to challenge. function challenge(address _safe) external { + // Check if the calling safe has configuration set _assertModuleConfigured(_safe); // Check that the module is still enabled on the target Safe. _assertModuleEnabled(_safe); + // Check that the caller is the fallback owner if (msg.sender != safeConfigs[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } + // Check that no challenge already exists if (challengeStartTime[_safe] != 0) { revert LivenessModule2_ChallengeAlreadyExists(); } + // Set the challenge start time and emit the event challengeStartTime[_safe] = block.timestamp; emit ChallengeStarted(_safe, block.timestamp); } /// @notice Responds to a challenge for an enabled safe, canceling it. function respond() external { - // Check that this module is enabled on the calling Safe. - _assertModuleEnabled(msg.sender); - // Check if the calling safe has configuration set. _assertModuleConfigured(msg.sender); + // Check that this module is enabled on the calling Safe. + _assertModuleEnabled(msg.sender); + + // Check that a challenge exists uint256 startTime = challengeStartTime[msg.sender]; if (startTime == 0) { revert LivenessModule2_ChallengeDoesNotExist(); diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index 27b8f164386..b92ac2dbbfe 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -365,28 +365,46 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } - function test_respond_moduleNotEnabled_reverts() external { + function test_respond_moduleNotConfigured_reverts() external { // Create a Safe that hasn't enabled the module (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("safeThatDidntEnable", NUM_OWNERS); SafeInstance memory safeThatDidntEnable = _setupSafe(newKeys, THRESHOLD); // Note: we don't call SafeTestLib.enableModule here - vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeThatDidntEnable.safe)); livenessModule2.respond(); } - function test_respond_moduleNotConfigured_reverts() external { - // Create a Safe that has the module enabled but never configured - (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("safeNotConfigured", NUM_OWNERS); - SafeInstance memory safeNotConfigured = _setupSafe(newKeys, THRESHOLD); + function test_respond_moduleNotEnabled_reverts() external { + // Create a Safe, enable and configure the module, then disable it + (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("configuredButDisabled", NUM_OWNERS); + SafeInstance memory configuredSafe = _setupSafe(newKeys, THRESHOLD); + + // First enable module at Safe level + SafeTestLib.enableModule(configuredSafe, address(livenessModule2)); - // Enable the module at Safe level but don't configure it - SafeTestLib.enableModule(safeNotConfigured, address(livenessModule2)); - // Note: we don't call _enableModule to configure it + // Configure the module (this sets the configuration) + _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner); - vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); - vm.prank(address(safeNotConfigured.safe)); + // Now disable the module at Safe level (but keep config) + SafeTestLib.execTransaction( + configuredSafe, + address(configuredSafe.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Verify the Safe still has configuration but module is not enabled + (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(configuredSafe.safe)); + assertTrue(period > 0); // Configuration exists + assertTrue(fbOwner != address(0)); // Configuration exists + assertFalse(configuredSafe.safe.isModuleEnabled(address(livenessModule2))); // Module not enabled + + // Now respond() should revert because module is not enabled + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(address(configuredSafe.safe)); livenessModule2.respond(); } } From 5be1c2d767b9f6b9ff66edad9497b285d1502d82 Mon Sep 17 00:00:00 2001 From: JosepBove Date: Wed, 17 Sep 2025 12:07:30 +0200 Subject: [PATCH 45/45] alcueca's comments --- .../interfaces/safe/ILivenessModule2.sol | 6 +- .../scripts/deploy/DeployOwnership.s.sol | 4 +- .../snapshots/abi/LivenessModule2.json | 24 +++---- .../snapshots/semver-lock.json | 4 +- .../storageLayout/LivenessModule2.json | 2 +- .../src/safe/LivenessModule2.sol | 66 ++++++++--------- .../test/safe/LivenessModule2.t.sol | 70 +++++++++++-------- .../test/scripts/DeployOwnership.t.sol | 2 +- 8 files changed, 96 insertions(+), 82 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol index 5cb99dd5909..64b367c50d2 100644 --- a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -15,7 +15,7 @@ interface ILivenessModule2 is ISemver { /// @notice Returns the configuration for a Safe /// @return livenessResponsePeriod The response period /// @return fallbackOwner The fallback owner address - function safeConfigs(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner); + 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 @@ -27,10 +27,10 @@ interface ILivenessModule2 is ISemver { /// @notice Configures the module for a Safe that has already enabled it /// @param _config The configuration parameters for the module - function configure(ModuleConfig memory _config) external; + function configureLivenessModule(ModuleConfig memory _config) external; /// @notice Clears the module configuration for a Safe - function clear() external; + 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 diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 7b303c82a4c..c93f547e4f0 100644 --- a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol +++ b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol @@ -309,7 +309,7 @@ contract DeployOwnership is Deploy { _safe: safe, _target: livenessModule, _data: abi.encodeCall( - LivenessModule2.configure, + LivenessModule2.configureLivenessModule, ( LivenessModule2.ModuleConfig({ livenessResponsePeriod: livenessModuleConfig.livenessInterval, @@ -324,7 +324,7 @@ contract DeployOwnership is Deploy { // Verify the module was configured correctly (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).safeConfigs(address(safe)); + LivenessModule2(livenessModule).livenessSafeConfiguration(address(safe)); require( configuredPeriod == exampleCouncilConfig.livenessModuleConfig.livenessInterval, "DeployOwnership: configured liveness interval must match expected value" diff --git a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json index 59053a989ca..8a4658b443d 100644 --- a/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -46,7 +46,7 @@ }, { "inputs": [], - "name": "clear", + "name": "clearLivenessModule", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -71,7 +71,7 @@ "type": "tuple" } ], - "name": "configure", + "name": "configureLivenessModule", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -95,13 +95,6 @@ "stateMutability": "view", "type": "function" }, - { - "inputs": [], - "name": "respond", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, { "inputs": [ { @@ -110,7 +103,7 @@ "type": "address" } ], - "name": "safeConfigs", + "name": "livenessSafeConfiguration", "outputs": [ { "internalType": "uint256", @@ -126,6 +119,13 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "respond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "version", @@ -200,7 +200,7 @@ "type": "address" } ], - "name": "ModuleDisabled", + "name": "ModuleCleared", "type": "event" }, { @@ -225,7 +225,7 @@ "type": "address" } ], - "name": "ModuleEnabled", + "name": "ModuleConfigured", "type": "event" }, { diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 1074b4656ca..a8af2e9c785 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -208,8 +208,8 @@ "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, "src/safe/LivenessModule2.sol:LivenessModule2": { - "initCodeHash": "0x7fbf205034d4507d37713d5a0a8018086a5c4855f1ce2fe1f9a72958b6b0bf15", - "sourceCodeHash": "0x9c455da81a4a7bedbfb304621bee819235961aadbd2313ae5bab1bcf6a1cff2b" + "initCodeHash": "0x4679b41e5648a955a883efd0271453c8b13ff4846f853d372527ebb1e0905ab5", + "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json index a9f1811395c..478b0b25136 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -1,7 +1,7 @@ [ { "bytes": "32", - "label": "safeConfigs", + "label": "livenessSafeConfiguration", "offset": 0, "slot": "0", "type": "mapping(address => struct LivenessModule2.ModuleConfig)" diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol index 4207a434c49..457f8678910 100644 --- a/packages/contracts-bedrock/src/safe/LivenessModule2.sol +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -29,7 +29,7 @@ contract LivenessModule2 is ISemver { } /// @notice Mapping from Safe address to its configuration. - mapping(address => ModuleConfig) public safeConfigs; + mapping(address => ModuleConfig) public livenessSafeConfiguration; /// @notice Mapping from Safe address to active challenge start time (0 if none). mapping(address => uint256) public challengeStartTime; @@ -71,17 +71,17 @@ contract LivenessModule2 is ISemver { /// @notice Error for when ownership transfer verification fails. error LivenessModule2_OwnershipTransferFailed(); - /// @notice Emitted when a Safe enables the module. - /// @param safe The Safe address that enabled the module. + /// @notice Emitted when a Safe configures the module. + /// @param safe The Safe address that configured the module. /// @param livenessResponsePeriod The duration in seconds that Safe owners have to /// respond to a challenge. /// @param fallbackOwner The address that can initiate challenges and claim ownership if /// the Safe is unresponsive. - event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); + event ModuleConfigured(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); - /// @notice Emitted when a Safe disables the module. - /// @param safe The Safe address that disabled the module. - event ModuleDisabled(address indexed safe); + /// @notice Emitted when a Safe clears the module configuration. + /// @param safe The Safe address that cleared the module configuration. + event ModuleCleared(address indexed safe); /// @notice Emitted when a challenge is started. /// @param safe The Safe address that started the challenge. @@ -110,14 +110,14 @@ contract LivenessModule2 is ISemver { if (startTime == 0) { return 0; } - ModuleConfig storage config = safeConfigs[_safe]; + ModuleConfig storage config = livenessSafeConfiguration[_safe]; return startTime + config.livenessResponsePeriod; } /// @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 configure(ModuleConfig memory _config) external { + function configureLivenessModule(ModuleConfig memory _config) external { // 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) { @@ -132,7 +132,7 @@ contract LivenessModule2 is ISemver { _assertModuleEnabled(msg.sender); // Store the configuration for this safe - safeConfigs[msg.sender] = _config; + livenessSafeConfiguration[msg.sender] = _config; // Clear any existing challenge when configuring/re-configuring. // This is necessary because changing the configuration (especially @@ -145,7 +145,7 @@ contract LivenessModule2 is ISemver { // theoretical standpoint. _cancelChallenge(msg.sender); - emit ModuleEnabled(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); + emit ModuleConfigured(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); } /// @notice Clears the module configuration for a Safe. @@ -153,11 +153,11 @@ contract LivenessModule2 is ISemver { /// This function is intended for use when a Safe wants to permanently remove /// the LivenessModule2 configuration. Typical usage pattern: /// 1. Safe disables the module via ModuleManager.disableModule(). - /// 2. Safe calls this clear() function to remove stored configuration. - /// 3. If Safe later re-enables the module, it must call configure() again. - /// Never calling clear() after disabling keeps configuration data persistent + /// 2. Safe calls this clearLivenessModule() function to remove stored configuration. + /// 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 clear() external { + function clearLivenessModule() external { // Check if the calling safe has configuration set _assertModuleConfigured(msg.sender); @@ -166,10 +166,10 @@ contract LivenessModule2 is ISemver { _assertModuleNotEnabled(msg.sender); // Erase the configuration data for this safe - delete safeConfigs[msg.sender]; + delete livenessSafeConfiguration[msg.sender]; // Also clear any active challenge _cancelChallenge(msg.sender); - emit ModuleDisabled(msg.sender); + emit ModuleCleared(msg.sender); } /// @notice Challenges an enabled safe. @@ -182,7 +182,7 @@ contract LivenessModule2 is ISemver { _assertModuleEnabled(_safe); // Check that the caller is the fallback owner - if (msg.sender != safeConfigs[_safe].fallbackOwner) { + if (msg.sender != livenessSafeConfiguration[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -230,7 +230,7 @@ contract LivenessModule2 is ISemver { _assertModuleEnabled(_safe); // Only fallback owner can execute ownership transfer (per specs update) - if (msg.sender != safeConfigs[_safe].fallbackOwner) { + if (msg.sender != livenessSafeConfiguration[_safe].fallbackOwner) { revert LivenessModule2_UnauthorizedCaller(); } @@ -269,25 +269,27 @@ contract LivenessModule2 is ISemver { to: _safe, value: 0, operation: Enum.Operation.Call, - data: abi.encodeCall(OwnerManager.swapOwner, (SENTINEL_OWNER, owners[0], safeConfigs[_safe].fallbackOwner)) + data: abi.encodeCall( + 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] != safeConfigs[_safe].fallbackOwner) { + 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, safeConfigs[_safe].fallbackOwner); + emit ChallengeSucceeded(_safe, livenessSafeConfiguration[_safe].fallbackOwner); } /// @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 = safeConfigs[_safe]; + ModuleConfig storage config = livenessSafeConfiguration[_safe]; if (config.fallbackOwner == address(0)) { revert LivenessModule2_ModuleNotConfigured(); } @@ -302,6 +304,15 @@ contract LivenessModule2 is ISemver { } } + /// @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))) { + revert LivenessModule2_ModuleStillEnabled(); + } + } + /// @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 { @@ -311,13 +322,4 @@ contract LivenessModule2 is ISemver { delete challengeStartTime[_safe]; emit ChallengeCancelled(_safe); } - - /// @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))) { - revert LivenessModule2_ModuleStillEnabled(); - } - } } diff --git a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol index b92ac2dbbfe..e552e6a00f2 100644 --- a/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -13,8 +13,8 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { using SafeTestLib for SafeInstance; // Events - event ModuleEnabled(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); - event ModuleDisabled(address indexed safe); + event ModuleConfigured(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); + event ModuleCleared(address indexed safe); event ChallengeStarted(address indexed safe, uint256 challengeStartTime); event ChallengeCancelled(address indexed safe); event ChallengeSucceeded(address indexed safe, address fallbackOwner); @@ -56,7 +56,11 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { LivenessModule2.ModuleConfig memory config = LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); SafeTestLib.execTransaction( - _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.configure, (config)), Enum.Operation.Call + _safe, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.configureLivenessModule, (config)), + Enum.Operation.Call ); } @@ -73,7 +77,11 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { // Then clear the module configuration SafeTestLib.execTransaction( - _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.clear, ()), Enum.Operation.Call + _safe, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.clearLivenessModule, ()), + Enum.Operation.Call ); } @@ -87,20 +95,20 @@ contract LivenessModule2_TestInit is Test, SafeTestTools { /// @title LivenessModule2_Configure_Test /// @notice Tests configuring and clearing the module -contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { - function test_configure_succeeds() external { +contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestInit { + function test_configureLivenessModule_succeeds() external { vm.expectEmit(true, true, true, true); - emit ModuleEnabled(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); + emit ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); + (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period, CHALLENGE_PERIOD); assertEq(fbOwner, fallbackOwner); assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); } - function test_configure_multipleSafes_succeeds() external { + function test_configureLivenessModule_multipleSafes_succeeds() external { // Test that multiple independent safes can configure the module (, uint256[] memory keys1) = SafeTestLib.makeAddrsAndKeys("safe1", NUM_OWNERS); SafeInstance memory safe1 = _setupSafe(keys1, THRESHOLD); @@ -124,20 +132,20 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { _enableModule(safe3, 3 days, fallback3); // Verify each safe has independent configuration - (uint256 period1, address fb1) = livenessModule2.safeConfigs(address(safe1.safe)); + (uint256 period1, address fb1) = livenessModule2.livenessSafeConfiguration(address(safe1.safe)); assertEq(period1, 1 days); assertEq(fb1, fallback1); - (uint256 period2, address fb2) = livenessModule2.safeConfigs(address(safe2.safe)); + (uint256 period2, address fb2) = livenessModule2.livenessSafeConfiguration(address(safe2.safe)); assertEq(period2, 2 days); assertEq(fb2, fallback2); - (uint256 period3, address fb3) = livenessModule2.safeConfigs(address(safe3.safe)); + (uint256 period3, address fb3) = livenessModule2.livenessSafeConfiguration(address(safe3.safe)); assertEq(period3, 3 days); assertEq(fb3, fallback3); } - function test_configure_requiresSafeModuleInstallation_reverts() external { + function test_configureLivenessModule_requiresSafeModuleInstallation_reverts() external { // Create a safe that has NOT installed the module at the Safe level (, uint256[] memory newKeys) = SafeTestLib.makeAddrsAndKeys("newSafe", NUM_OWNERS); SafeInstance memory newSafe = _setupSafe(newKeys, THRESHOLD); @@ -146,30 +154,30 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { // Now configure should revert because the module is not enabled at the Safe level vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); vm.prank(address(newSafe.safe)); - livenessModule2.configure( + livenessModule2.configureLivenessModule( LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) ); } - function test_configure_invalidResponsePeriod_reverts() external { + function test_configureLivenessModule_invalidResponsePeriod_reverts() external { // Test with zero period vm.expectRevert(LivenessModule2.LivenessModule2_InvalidResponsePeriod.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.configure( + livenessModule2.configureLivenessModule( LivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) ); } - function test_configure_invalidFallbackOwner_reverts() external { + function test_configureLivenessModule_invalidFallbackOwner_reverts() external { // Test with zero address vm.expectRevert(LivenessModule2.LivenessModule2_InvalidFallbackOwner.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.configure( + livenessModule2.configureLivenessModule( LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) ); } - function test_configure_cancelsExistingChallenge_succeeds() external { + function test_configureLivenessModule_cancelsExistingChallenge_succeeds() external { // First configure the module _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); @@ -185,10 +193,10 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { vm.expectEmit(true, true, true, true); emit ChallengeCancelled(address(safeInstance.safe)); vm.expectEmit(true, true, true, true); - emit ModuleEnabled(address(safeInstance.safe), CHALLENGE_PERIOD * 2, fallbackOwner); + emit ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD * 2, fallbackOwner); vm.prank(address(safeInstance.safe)); - livenessModule2.configure( + livenessModule2.configureLivenessModule( LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD * 2, fallbackOwner: fallbackOwner }) ); @@ -210,14 +218,18 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { ); vm.expectEmit(true, true, true, true); - emit ModuleDisabled(address(safeInstance.safe)); + emit ModuleCleared(address(safeInstance.safe)); // Now clear the configuration SafeTestLib.execTransaction( - safeInstance, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.clear, ()), Enum.Operation.Call + safeInstance, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.clearLivenessModule, ()), + Enum.Operation.Call ); - (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(safeInstance.safe)); + (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period, 0); assertEq(fbOwner, address(0)); } @@ -225,7 +237,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { function test_clear_notEnabled_reverts() external { vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.clear(); + livenessModule2.clearLivenessModule(); } function test_clear_moduleStillEnabled_reverts() external { @@ -234,7 +246,7 @@ contract LivenessModule2_Configure_Test is LivenessModule2_TestInit { // Try to clear while module is still enabled (should revert) vm.expectRevert(LivenessModule2.LivenessModule2_ModuleStillEnabled.selector); vm.prank(address(safeInstance.safe)); - livenessModule2.clear(); + livenessModule2.clearLivenessModule(); } } @@ -397,7 +409,7 @@ contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { ); // Verify the Safe still has configuration but module is not enabled - (uint256 period, address fbOwner) = livenessModule2.safeConfigs(address(configuredSafe.safe)); + (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 @@ -538,14 +550,14 @@ contract LivenessModule2_ChangeOwnershipToFallback_Test is LivenessModule2_TestI contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit { function test_safeConfigs_succeeds() external { // Before enabling - (uint256 period1, address fbOwner1) = livenessModule2.safeConfigs(address(safeInstance.safe)); + (uint256 period1, address fbOwner1) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period1, 0); assertEq(fbOwner1, address(0)); assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); // After enabling _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); - (uint256 period2, address fbOwner2) = livenessModule2.safeConfigs(address(safeInstance.safe)); + (uint256 period2, address fbOwner2) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); assertEq(period2, CHALLENGE_PERIOD); assertEq(fbOwner2, fallbackOwner); assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index 5d5b57b5d8a..ff175d3f6b2 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -70,7 +70,7 @@ contract DeployOwnershipTest is Test, DeployOwnership { // LivenessModule2 checks LivenessModuleConfig memory lmConfig = exampleSecurityCouncilConfig.livenessModuleConfig; (uint256 configuredPeriod, address configuredFallback) = - LivenessModule2(livenessModule).safeConfigs(address(securityCouncilSafe)); + LivenessModule2(livenessModule).livenessSafeConfiguration(address(securityCouncilSafe)); assertEq(configuredPeriod, lmConfig.livenessInterval); assertEq(configuredFallback, lmConfig.fallbackOwner);