diff --git a/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol new file mode 100644 index 00000000000..64b367c50d2 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ILivenessModule2.sol @@ -0,0 +1,50 @@ +// 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 Configuration for a Safe's liveness module + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } + + /// @notice Returns the configuration for a Safe + /// @return livenessResponsePeriod The response period + /// @return fallbackOwner The fallback owner address + function livenessSafeConfiguration(address) external view returns (uint256 livenessResponsePeriod, address fallbackOwner); + + /// @notice Returns the challenge start time for a Safe (0 if no challenge) + /// @return The challenge start timestamp + function challengeStartTime(address) external view returns (uint256); + + /// @notice Semantic version + /// @return version The contract version + function version() external view returns (string memory); + + /// @notice Configures the module for a Safe that has already enabled it + /// @param _config The configuration parameters for the module + function configureLivenessModule(ModuleConfig memory _config) external; + + /// @notice Clears the module configuration for a Safe + function clearLivenessModule() external; + + /// @notice Returns challenge_start_time + liveness_response_period if there is a challenge, or 0 if not + /// @param _safe The Safe address to query + /// @return The challenge end timestamp, or 0 if no challenge + function getChallengePeriodEnd(address _safe) external view returns (uint256); + + /// @notice Challenges an enabled safe + /// @param _safe The Safe to challenge + function challenge(address _safe) external; + + /// @notice Responds to a challenge for an enabled safe, canceling it + function respond() external; + + /// @notice Removes all current owners from an enabled safe and appoints fallback as sole owner + /// @param _safe The Safe to transfer ownership of + function changeOwnershipToFallback(address _safe) external; +} diff --git a/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployOwnership.s.sol index 13914f167dc..c93f547e4f0 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 { LivenessGuard } from "src/safe/LivenessGuard.sol"; -import { LivenessModule } from "src/safe/LivenessModule.sol"; +import { LivenessModule2 } from "src/safe/LivenessModule2.sol"; import { ISuperchainConfig } from "interfaces/L1/ISuperchainConfig.sol"; import { Deploy } from "./Deploy.s.sol"; @@ -240,36 +238,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 +295,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 +303,35 @@ 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.configureLivenessModule, + ( + LivenessModule2.ModuleConfig({ + livenessResponsePeriod: livenessModuleConfig.livenessInterval, + fallbackOwner: 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).livenessSafeConfiguration(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/snapshots/abi/LivenessModule2.json b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json new file mode 100644 index 00000000000..8a4658b443d --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/LivenessModule2.json @@ -0,0 +1,286 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "challenge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "challengeStartTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "changeOwnershipToFallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "clearLivenessModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "internalType": "struct LivenessModule2.ModuleConfig", + "name": "_config", + "type": "tuple" + } + ], + "name": "configureLivenessModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "getChallengePeriodEnd", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "livenessSafeConfiguration", + "outputs": [ + { + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "respond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "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": "uint256", + "name": "challengeStartTime", + "type": "uint256" + } + ], + "name": "ChallengeStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ChallengeSucceeded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "ModuleCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "livenessResponsePeriod", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackOwner", + "type": "address" + } + ], + "name": "ModuleConfigured", + "type": "event" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeAlreadyExists", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ChallengeDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidFallbackOwner", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_InvalidResponsePeriod", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ModuleStillEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_OwnershipTransferFailed", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ResponsePeriodActive", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_ResponsePeriodEnded", + "type": "error" + }, + { + "inputs": [], + "name": "LivenessModule2_UnauthorizedCaller", + "type": "error" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index a97ab39d19e..cca5b87af32 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -207,6 +207,10 @@ "initCodeHash": "0xde3b3273aa37604048b5fa228b90f3b05997db613dfcda45061545a669b2476a", "sourceCodeHash": "0x918965e52bbd358ac827ebe35998f5d8fa5ca77d8eb9ab8986b44181b9aaa48a" }, + "src/safe/LivenessModule2.sol:LivenessModule2": { + "initCodeHash": "0x4679b41e5648a955a883efd0271453c8b13ff4846f853d372527ebb1e0905ab5", + "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" + }, "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..478b0b25136 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/LivenessModule2.json @@ -0,0 +1,16 @@ +[ + { + "bytes": "32", + "label": "livenessSafeConfiguration", + "offset": 0, + "slot": "0", + "type": "mapping(address => struct LivenessModule2.ModuleConfig)" + }, + { + "bytes": "32", + "label": "challengeStartTime", + "offset": 0, + "slot": "1", + "type": "mapping(address => uint256)" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/safe/LivenessModule2.sol b/packages/contracts-bedrock/src/safe/LivenessModule2.sol new file mode 100644 index 00000000000..457f8678910 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/LivenessModule2.sol @@ -0,0 +1,325 @@ +// 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 { ISemver } from "interfaces/universal/ISemver.sol"; + +/// @title LivenessModule2 +/// @notice This module allows challenge-based ownership transfer to a fallback owner +/// when the Safe becomes unresponsive. The fallback owner can initiate a challenge, +/// 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 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. + struct ModuleConfig { + uint256 livenessResponsePeriod; + address fallbackOwner; + } + + /// @notice Mapping from Safe address to its configuration. + mapping(address => ModuleConfig) public livenessSafeConfiguration; + + /// @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. + 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 response period has ended. + error LivenessModule2_ResponsePeriodEnded(); + + /// @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. + error LivenessModule2_UnauthorizedCaller(); + + /// @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(); + + /// @notice Error for when ownership transfer verification fails. + error LivenessModule2_OwnershipTransferFailed(); + + /// @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 ModuleConfigured(address indexed safe, uint256 livenessResponsePeriod, address fallbackOwner); + + /// @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. + /// @param challengeStartTime The timestamp when the challenge started. + event ChallengeStarted(address indexed safe, uint256 challengeStartTime); + + /// @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. + /// @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. + /// @custom:semver 2.0.0 + 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. + function getChallengePeriodEnd(address _safe) public view returns (uint256) { + uint256 startTime = challengeStartTime[_safe]; + if (startTime == 0) { + return 0; + } + 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 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) { + revert LivenessModule2_InvalidResponsePeriod(); + } + // fallbackOwner must not be zero address to have a valid ownership recipient. + if (_config.fallbackOwner == address(0)) { + revert LivenessModule2_InvalidFallbackOwner(); + } + + // Check that this module is enabled on the calling Safe. + _assertModuleEnabled(msg.sender); + + // Store the configuration for this safe + livenessSafeConfiguration[msg.sender] = _config; + + // 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. + // 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 ModuleConfigured(msg.sender, _config.livenessResponsePeriod, _config.fallbackOwner); + } + + /// @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 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 clearLivenessModule() 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); + + // Erase the configuration data for this safe + delete livenessSafeConfiguration[msg.sender]; + // Also clear any active challenge + _cancelChallenge(msg.sender); + emit ModuleCleared(msg.sender); + } + + /// @notice Challenges an enabled safe. + /// @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 != livenessSafeConfiguration[_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 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(); + } + + // 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, + /// 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. + _assertModuleConfigured(_safe); + + // 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 != livenessSafeConfiguration[_safe].fallbackOwner) { + revert LivenessModule2_UnauthorizedCaller(); + } + + // Verify active challenge exists - without challenge, ownership transfer not allowed + uint256 startTime = challengeStartTime[_safe]; + if (startTime == 0) { + revert LivenessModule2_ChallengeDoesNotExist(); + } + + // 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(); + } + + Safe targetSafe = Safe(payable(_safe)); + + // Get current owners + 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) { + targetSafe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + data: abi.encodeCall(OwnerManager.removeOwner, (SENTINEL_OWNER, owners[0], 1)) + }); + owners = targetSafe.getOwners(); + } + + // Now swap the remaining single owner with the fallback owner + targetSafe.execTransactionFromModule({ + to: _safe, + value: 0, + operation: Enum.Operation.Call, + 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] != livenessSafeConfiguration[_safe].fallbackOwner) { + revert LivenessModule2_OwnershipTransferFailed(); + } + + // Reset the challenge state to allow a new challenge + delete challengeStartTime[_safe]; + + 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 = livenessSafeConfiguration[_safe]; + if (config.fallbackOwner == address(0)) { + revert LivenessModule2_ModuleNotConfigured(); + } + } + + /// @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))) { + revert LivenessModule2_ModuleNotEnabled(); + } + } + + /// @notice Asserts that the module is not enabled for the given Safe. + /// @param _safe The Safe address to check. + function _assertModuleNotEnabled(address _safe) internal view { + Safe safe = Safe(payable(_safe)); + if (safe.isModuleEnabled(address(this))) { + 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 { + // Early return if no challenge exists + if (challengeStartTime[_safe] == 0) return; + + delete challengeStartTime[_safe]; + emit ChallengeCancelled(_safe); + } +} 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..e552e6a00f2 --- /dev/null +++ b/packages/contracts-bedrock/test/safe/LivenessModule2.t.sol @@ -0,0 +1,585 @@ +// 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"; + +/// @title LivenessModule2_TestInit +/// @notice Reusable test initialization for `LivenessModule2` tests. +contract LivenessModule2_TestInit is Test, SafeTestTools { + using SafeTestLib for SafeInstance; + + // Events + 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); + + 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 { + LivenessModule2.ModuleConfig memory config = + LivenessModule2.ModuleConfig({ livenessResponsePeriod: _period, fallbackOwner: _fallback }); + SafeTestLib.execTransaction( + _safe, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.configureLivenessModule, (config)), + Enum.Operation.Call + ); + } + + /// @notice Helper to disable the LivenessModule2 for a Safe + function _disableModule(SafeInstance memory _safe) internal { + // First disable the module at the Safe level + SafeTestLib.execTransaction( + _safe, + address(_safe.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Then clear the module configuration + SafeTestLib.execTransaction( + _safe, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.clearLivenessModule, ()), + Enum.Operation.Call + ); + } + + /// @notice Helper to respond to a challenge from a Safe + function _respondToChallenge(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, address(livenessModule2), 0, abi.encodeCall(LivenessModule2.respond, ()), Enum.Operation.Call + ); + } +} + +/// @title LivenessModule2_Configure_Test +/// @notice Tests configuring and clearing the module +contract LivenessModule2_ConfigureLivenessModule_Test is LivenessModule2_TestInit { + function test_configureLivenessModule_succeeds() external { + vm.expectEmit(true, true, true, true); + emit ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD, fallbackOwner); + + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(period, CHALLENGE_PERIOD); + assertEq(fbOwner, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + } + + 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); + 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"); + + // 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.livenessSafeConfiguration(address(safe1.safe)); + assertEq(period1, 1 days); + assertEq(fb1, fallback1); + + (uint256 period2, address fb2) = livenessModule2.livenessSafeConfiguration(address(safe2.safe)); + assertEq(period2, 2 days); + assertEq(fb2, fallback2); + + (uint256 period3, address fb3) = livenessModule2.livenessSafeConfiguration(address(safe3.safe)); + assertEq(period3, 3 days); + assertEq(fb3, fallback3); + } + + 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); + // Note: we don't call SafeTestLib.enableModule here + + // 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.configureLivenessModule( + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: fallbackOwner }) + ); + } + + function test_configureLivenessModule_invalidResponsePeriod_reverts() external { + // Test with zero period + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidResponsePeriod.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.configureLivenessModule( + LivenessModule2.ModuleConfig({ livenessResponsePeriod: 0, fallbackOwner: fallbackOwner }) + ); + } + + function test_configureLivenessModule_invalidFallbackOwner_reverts() external { + // Test with zero address + vm.expectRevert(LivenessModule2.LivenessModule2_InvalidFallbackOwner.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.configureLivenessModule( + LivenessModule2.ModuleConfig({ livenessResponsePeriod: CHALLENGE_PERIOD, fallbackOwner: address(0) }) + ); + } + + function test_configureLivenessModule_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 ModuleConfigured(address(safeInstance.safe), CHALLENGE_PERIOD * 2, fallbackOwner); + + vm.prank(address(safeInstance.safe)); + livenessModule2.configureLivenessModule( + LivenessModule2.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); + + // 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 ModuleCleared(address(safeInstance.safe)); + + // Now clear the configuration + SafeTestLib.execTransaction( + safeInstance, + address(livenessModule2), + 0, + abi.encodeCall(LivenessModule2.clearLivenessModule, ()), + Enum.Operation.Call + ); + + (uint256 period, address fbOwner) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(period, 0); + assertEq(fbOwner, address(0)); + } + + function test_clear_notEnabled_reverts() external { + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.clearLivenessModule(); + } + + function test_clear_moduleStillEnabled_reverts() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // Try to clear while module is still enabled (should revert) + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleStillEnabled.selector); + vm.prank(address(safeInstance.safe)); + livenessModule2.clearLivenessModule(); + } +} + +/// @title LivenessModule2_Challenge_Test +/// @notice Tests the challenge mechanism +contract LivenessModule2_Challenge_Test is LivenessModule2_TestInit { + function setUp() public override { + super.setUp(); + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + } + + function test_challenge_succeeds() external { + vm.expectEmit(true, true, true, true); + emit ChallengeStarted(address(safeInstance.safe), block.timestamp); + + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertEq(challengeEndTime, block.timestamp + CHALLENGE_PERIOD); + } + + function test_challenge_notFallbackOwner_reverts() external { + address notFallback = makeAddr("notFallback"); + + vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + vm.prank(notFallback); + livenessModule2.challenge(address(safeInstance.safe)); + } + + function test_challenge_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + vm.prank(fallbackOwner); + livenessModule2.challenge(newSafe); + } + + function test_challenge_alreadyExists_reverts() external { + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeAlreadyExists.selector); + vm.prank(fallbackOwner); + 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); + + // 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) + 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(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + vm.prank(fallbackOwner); + livenessModule2.challenge(address(disabledSafe.safe)); + } + + function test_respond_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Cancel it + vm.expectEmit(true, true, true, true); + emit ChallengeCancelled(address(safeInstance.safe)); + + _respondToChallenge(safeInstance); + + // Verify challenge is cancelled + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + } + + 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.respond, ()); + 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_respond_afterResponsePeriod_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // 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_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_ModuleNotConfigured.selector); + vm.prank(address(safeThatDidntEnable.safe)); + livenessModule2.respond(); + } + + 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)); + + // Configure the module (this sets the configuration) + _enableModule(configuredSafe, CHALLENGE_PERIOD, fallbackOwner); + + // 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.livenessSafeConfiguration(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(); + } +} + +/// @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.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Execute ownership transfer + vm.expectEmit(true, true, true, true); + emit ChallengeSucceeded(address(safeInstance.safe), fallbackOwner); + + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + assertEq(safeInstance.safe.getThreshold(), 1); + + // Verify challenge is reset + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertEq(challengeEndTime, 0); + } + + function test_changeOwnershipToFallback_moduleNotEnabled_reverts() external { + address newSafe = makeAddr("newSafe"); + + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotConfigured.selector); + livenessModule2.changeOwnershipToFallback(newSafe); + } + + function test_changeOwnershipToFallback_noChallenge_reverts() external { + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ChallengeDoesNotExist.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_beforeResponsePeriod_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Try to execute before response period expires + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ResponsePeriodActive.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_moduleDisabledAtSafeLevel_reverts() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Disable the module at Safe level + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(ModuleManager.disableModule, (address(0x1), address(livenessModule2))), + Enum.Operation.Call + ); + + // Try to execute ownership transfer - should revert because module is disabled at Safe level + vm.prank(fallbackOwner); + vm.expectRevert(LivenessModule2.LivenessModule2_ModuleNotEnabled.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + } + + function test_changeOwnershipToFallback_onlyFallbackOwner_succeeds() external { + // Start a challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + // Warp past challenge period + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + + // Try from random address - should fail + address randomCaller = makeAddr("randomCaller"); + vm.prank(randomCaller); + vm.expectRevert(LivenessModule2.LivenessModule2_UnauthorizedCaller.selector); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Execute from fallback owner - should succeed + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Verify ownership changed + address[] memory newOwners = safeInstance.safe.getOwners(); + assertEq(newOwners.length, 1); + assertEq(newOwners[0], fallbackOwner); + } + + function test_changeOwnershipToFallback_canRechallenge_succeeds() external { + // Start and execute first challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + vm.warp(block.timestamp + CHALLENGE_PERIOD + 1); + vm.prank(fallbackOwner); + livenessModule2.changeOwnershipToFallback(address(safeInstance.safe)); + + // Start a new challenge (as fallback owner) + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + + uint256 challengeEndTime = livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)); + assertGt(challengeEndTime, 0); + } +} + +/// @title LivenessModule2_GetChallengePeriodEnd_Test +/// @notice Tests the getChallengePeriodEnd function and related view functionality +contract LivenessModule2_GetChallengePeriodEnd_Test is LivenessModule2_TestInit { + function test_safeConfigs_succeeds() external { + // Before enabling + (uint256 period1, address fbOwner1) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(period1, 0); + assertEq(fbOwner1, address(0)); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + + // After enabling + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + (uint256 period2, address fbOwner2) = livenessModule2.livenessSafeConfiguration(address(safeInstance.safe)); + assertEq(period2, CHALLENGE_PERIOD); + assertEq(fbOwner2, fallbackOwner); + assertEq(livenessModule2.challengeStartTime(address(safeInstance.safe)), 0); + } + + function test_getChallengePeriodEnd_succeeds() external { + _enableModule(safeInstance, CHALLENGE_PERIOD, fallbackOwner); + + // No challenge + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); + + // With challenge + vm.prank(fallbackOwner); + livenessModule2.challenge(address(safeInstance.safe)); + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), block.timestamp + CHALLENGE_PERIOD); + + // After cancellation + _respondToChallenge(safeInstance); + assertEq(livenessModule2.getChallengePeriodEnd(address(safeInstance.safe)), 0); + } + + function test_version_succeeds() external view { + assertTrue(bytes(livenessModule2.version()).length > 0); + } +} diff --git a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol index ff1506d1c55..ff175d3f6b2 100644 --- a/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol +++ b/packages/contracts-bedrock/test/scripts/DeployOwnership.t.sol @@ -12,8 +12,7 @@ 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"; contract DeployOwnershipTest is Test, DeployOwnership { address internal constant SENTINEL_MODULES = address(0x1); @@ -60,35 +59,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).livenessSafeConfiguration(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).getChallengePeriodEnd(address(securityCouncilSafe)), 0); } }