From cc5c478f789376e586d364289c3995a593241def Mon Sep 17 00:00:00 2001 From: JohnGuilding Date: Sat, 6 Jul 2024 13:01:34 +0200 Subject: [PATCH] Add native safe recovery module --- foundry.toml | 4 +- script/DeploySafeNativeRecovery.s.sol | 42 ++ src/interfaces/ISafe.sol | 9 + src/modules/SafeEmailRecoveryModule.sol | 569 ++++++++++++++++++ .../SafeNativeIntegrationBase.t.sol | 224 +++++++ .../SafeRecoveryNativeModule.t.sol | 98 +++ 6 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 script/DeploySafeNativeRecovery.s.sol create mode 100644 src/modules/SafeEmailRecoveryModule.sol create mode 100644 test/integration/SafeRecovery/SafeNativeIntegrationBase.t.sol create mode 100644 test/integration/SafeRecovery/SafeRecoveryNativeModule.t.sol diff --git a/foundry.toml b/foundry.toml index beec336..6e27de2 100644 --- a/foundry.toml +++ b/foundry.toml @@ -24,8 +24,8 @@ ignored_warnings_from = [ [rpc_endpoints] sepolia = "${BASE_SEPOLIA_RPC_URL}" -# [etherscan] -# sepolia = { key = "${BASE_SCAN_API_KEY}" } +[etherscan] +sepolia = { key = "${BASE_SCAN_API_KEY}" } [fmt] bracket_spacing = true diff --git a/script/DeploySafeNativeRecovery.s.sol b/script/DeploySafeNativeRecovery.s.sol new file mode 100644 index 0000000..087eccf --- /dev/null +++ b/script/DeploySafeNativeRecovery.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { Script } from "forge-std/Script.sol"; +import { console } from "forge-std/console.sol"; +import { SafeEmailRecoveryModule } from "src/modules/SafeEmailRecoveryModule.sol"; +import { Verifier } from "ether-email-auth/packages/contracts/src/utils/Verifier.sol"; +import { ECDSAOwnedDKIMRegistry } from + "ether-email-auth/packages/contracts/src/utils/ECDSAOwnedDKIMRegistry.sol"; +import { EmailAuth } from "ether-email-auth/packages/contracts/src/EmailAuth.sol"; + +contract DeploySafeNativeRecovery_Script is Script { + function run() public { + vm.startBroadcast(vm.envUint("PRIVATE_KEY")); + address verifier = vm.envOr("VERIFIER", address(0)); + address dkimRegistry = vm.envOr("DKIM_REGISTRY", address(0)); + address dkimRegistrySigner = vm.envOr("SIGNER", address(0)); + address emailAuthImpl = vm.envOr("EMAIL_AUTH_IMPL", address(0)); + + if (verifier == address(0)) { + verifier = address(new Verifier()); + console.log("Deployed Verifier at", verifier); + } + + if (dkimRegistry == address(0)) { + require(dkimRegistrySigner != address(0), "DKIM_REGISTRY_SIGNER is required"); + dkimRegistry = address(new ECDSAOwnedDKIMRegistry(dkimRegistrySigner)); + console.log("Deployed DKIM Registry at", dkimRegistry); + } + + if (emailAuthImpl == address(0)) { + emailAuthImpl = address(new EmailAuth()); + console.log("Deployed Email Auth at", emailAuthImpl); + } + + address module = address(new SafeEmailRecoveryModule(verifier, emailAuthImpl, dkimRegistry)); + + console.log("Deployed Email Recovery Module at ", vm.toString(module)); + + vm.stopBroadcast(); + } +} diff --git a/src/interfaces/ISafe.sol b/src/interfaces/ISafe.sol index e8d0adb..c0304f1 100644 --- a/src/interfaces/ISafe.sol +++ b/src/interfaces/ISafe.sol @@ -7,4 +7,13 @@ interface ISafe { function getOwners() external view returns (address[] memory); function setFallbackHandler(address handler) external; function setGuard(address guard) external; + function execTransactionFromModule( + address to, + uint256 value, + bytes memory data, + uint8 operation + ) + external + returns (bool success); + function isModuleEnabled(address module) external view returns (bool); } diff --git a/src/modules/SafeEmailRecoveryModule.sol b/src/modules/SafeEmailRecoveryModule.sol new file mode 100644 index 0000000..1d182e1 --- /dev/null +++ b/src/modules/SafeEmailRecoveryModule.sol @@ -0,0 +1,569 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { EmailAccountRecovery } from + "ether-email-auth/packages/contracts/src/EmailAccountRecovery.sol"; +import { IEmailRecoveryManager } from "../interfaces/IEmailRecoveryManager.sol"; +import { IEmailRecoverySubjectHandler } from "../interfaces/IEmailRecoverySubjectHandler.sol"; +import { IEmailRecoveryModule } from "../interfaces/IEmailRecoveryModule.sol"; +import { + EnumerableGuardianMap, + GuardianStorage, + GuardianStatus +} from "../libraries/EnumerableGuardianMap.sol"; +import { GuardianUtils } from "../libraries/GuardianUtils.sol"; +import { ISafe } from "../interfaces/ISafe.sol"; +import { console2 } from "forge-std/console2.sol"; + +/** + * A safe plugin that recovers a safe owner via a zkp of an email. + */ +contract SafeEmailRecoveryModule is EmailAccountRecovery, IEmailRecoveryManager { + using GuardianUtils for mapping(address => GuardianConfig); + using GuardianUtils for mapping(address => EnumerableGuardianMap.AddressToGuardianMap); + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONSTANTS & STORAGE */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * Minimum required time window between when a recovery attempt becomes valid and when it + * becomes invalid + */ + uint256 public constant MINIMUM_RECOVERY_WINDOW = 2 days; + + /** + * Account address to recovery config + */ + mapping(address account => RecoveryConfig recoveryConfig) internal recoveryConfigs; + + /** + * Account address to recovery request + */ + mapping(address account => RecoveryRequest recoveryRequest) internal recoveryRequests; + + /** + * Account to guardian config + */ + mapping(address account => GuardianConfig guardianConfig) internal guardianConfigs; + + /** + * Account address to guardian address to guardian storage + */ + mapping(address account => EnumerableGuardianMap.AddressToGuardianMap guardian) internal + guardiansStorage; + + error InvalidSubjectParams(); + error InvalidOldOwner(); + error InvalidNewOwner(); + + constructor(address _verifier, address _dkimRegistry, address _emailAuthImpl) { + verifierAddr = _verifier; + dkimAddr = _dkimRegistry; + emailAuthImplementationAddr = _emailAuthImpl; + } + + /** + * @notice Modifier to check recovery status. Reverts if recovery is in process for the account + */ + modifier onlyWhenNotRecovering() { + if (recoveryRequests[msg.sender].currentWeight > 0) { + revert RecoveryInProcess(); + } + _; + } + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* RECOVERY CONFIG, REQUEST AND TEMPLATE GETTERS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Retrieves the recovery configuration for a given account + * @param account The address of the account for which the recovery configuration is being + * retrieved + * @return RecoveryConfig The recovery configuration for the specified account + */ + function getRecoveryConfig(address account) external view returns (RecoveryConfig memory) { + return recoveryConfigs[account]; + } + + /** + * @notice Retrieves the recovery request details for a given account + * @param account The address of the account for which the recovery request details are being + * retrieved + * @return RecoveryRequest The recovery request details for the specified account + */ + function getRecoveryRequest(address account) external view returns (RecoveryRequest memory) { + return recoveryRequests[account]; + } + + /** + * @notice Returns a two-dimensional array of strings representing the subject templates for an + * acceptance by a new guardian. + * @dev This is retrieved from the associated subject handler. Developers can write their own + * subject handlers, this is useful for account implementations which require different data in + * the subject or if the email should be in a language that is not English. + * @return string[][] A two-dimensional array of strings, where each inner array represents a + * set of fixed strings and matchers for a subject template. + */ + function acceptanceSubjectTemplates() public view override returns (string[][] memory) { + string[][] memory templates = new string[][](1); + templates[0] = new string[](5); + templates[0][0] = "Accept"; + templates[0][1] = "guardian"; + templates[0][2] = "request"; + templates[0][3] = "for"; + templates[0][4] = "{ethAddr}"; + return templates; + } + + /** + * @notice Returns a two-dimensional array of strings representing the subject templates for + * email recovery. + * @dev This is retrieved from the associated subject handler. Developers can write their own + * subject handlers, this is useful for account implementations which require different data in + * the subject or if the email should be in a language that is not English. + * @return string[][] A two-dimensional array of strings, where each inner array represents a + * set of fixed strings and matchers for a subject template. + */ + function recoverySubjectTemplates() public view override returns (string[][] memory) { + string[][] memory templates = new string[][](1); + templates[0] = new string[](15); + templates[0][0] = "Recover"; + templates[0][1] = "account"; + templates[0][2] = "{ethAddr}"; + templates[0][3] = "from"; + templates[0][4] = "old"; + templates[0][5] = "owner"; + templates[0][6] = "{ethAddr}"; + templates[0][7] = "to"; + templates[0][8] = "new"; + templates[0][9] = "owner"; + templates[0][10] = "{ethAddr}"; + templates[0][11] = "using"; + templates[0][12] = "recovery"; + templates[0][13] = "module"; + templates[0][14] = "{ethAddr}"; + return templates; + } + + /** + * @notice Extracts the account address to be recovered from the subject parameters of an + * acceptance email. + * @dev This is retrieved from the associated subject handler. + * @param subjectParams The subject parameters of the acceptance email. + * @param templateIdx The index of the acceptance subject template. + */ + function extractRecoveredAccountFromAcceptanceSubject( + bytes[] memory subjectParams, + uint256 templateIdx + ) + public + view + override + returns (address) + { + return abi.decode(subjectParams[0], (address)); + } + + /** + * @notice Extracts the account address to be recovered from the subject parameters of a + * recovery email. + * @dev This is retrieved from the associated subject handler. + * @param subjectParams The subject parameters of the recovery email. + * @param templateIdx The index of the recovery subject template. + */ + function extractRecoveredAccountFromRecoverySubject( + bytes[] memory subjectParams, + uint256 templateIdx + ) + public + view + override + returns (address) + { + return abi.decode(subjectParams[0], (address)); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CONFIGURE RECOVERY */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Configures recovery for the caller's account. This is the first core function + * that must be called during the end-to-end recovery flow + * @dev Can only be called once for configuration. Sets up the guardians, and validates config + * parameters, ensuring that no recovery is in process. It is possible to configure guardians at + * a later stage if neccessary + * @param guardians An array of guardian addresses + * @param weights An array of weights corresponding to each guardian + * @param threshold The threshold weight required for recovery + * @param delay The delay period before recovery can be executed + * @param expiry The expiry time after which the recovery attempt is invalid + */ + function configureRecovery( + address[] memory guardians, + uint256[] memory weights, + uint256 threshold, + uint256 delay, + uint256 expiry + ) + external + { + address account = msg.sender; + + // Threshold can only be 0 at initialization. + // Check ensures that setup function can only be called once. + if (guardianConfigs[account].threshold > 0) { + revert SetupAlreadyCalled(); + } + + bool moduleEnabled = ISafe(account).isModuleEnabled(address(this)); + if (!moduleEnabled) { + revert RecoveryModuleNotAuthorized(); + } + + // Allow recovery configuration without configuring guardians + if (guardians.length == 0 && weights.length == 0 && threshold == 0) { + guardianConfigs[account].initialized = true; + } else { + setupGuardians(account, guardians, weights, threshold); + } + + RecoveryConfig memory recoveryConfig = RecoveryConfig(delay, expiry); + updateRecoveryConfig(recoveryConfig); + + emit RecoveryConfigured(account, guardians.length); + } + + /** + * @notice Updates and validates the recovery configuration for the caller's account + * @dev Validates and sets the new recovery configuration for the caller's account, ensuring + * that no recovery is in process. + * @param recoveryConfig The new recovery configuration to be set for the caller's account + */ + function updateRecoveryConfig(RecoveryConfig memory recoveryConfig) + public + onlyWhenNotRecovering + { + address account = msg.sender; + + if (!guardianConfigs[account].initialized) { + revert AccountNotConfigured(); + } + if (recoveryConfig.delay > recoveryConfig.expiry) { + revert DelayMoreThanExpiry(); + } + if (recoveryConfig.expiry - recoveryConfig.delay < MINIMUM_RECOVERY_WINDOW) { + revert RecoveryWindowTooShort(); + } + + recoveryConfigs[account] = recoveryConfig; + + emit RecoveryConfigUpdated(account, recoveryConfig.delay, recoveryConfig.expiry); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* HANDLE ACCEPTANCE */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Accepts a guardian for the specified account. This is the second core function + * that must be called during the end-to-end recovery flow + * @dev Called once per guardian added. Although this adds an extra step to recovery, this + * acceptance flow is an important security feature to ensure that no typos are made when adding + * a guardian, and that the guardian is in control of the specified email address. Called as + * part of handleAcceptance in EmailAccountRecovery + * @param guardian The address of the guardian to be accepted + * @param templateIdx The index of the template used for acceptance + * @param subjectParams An array of bytes containing the subject parameters + */ + function acceptGuardian( + address guardian, + uint256 templateIdx, + bytes[] memory subjectParams, + bytes32 + ) + internal + override + { + if (templateIdx != 0) { + revert InvalidTemplateIndex(); + } + if (subjectParams.length != 1) revert InvalidSubjectParams(); + + address account = abi.decode(subjectParams[0], (address)); + + if (recoveryRequests[account].currentWeight > 0) { + revert RecoveryInProcess(); + } + + bool moduleEnabled = ISafe(account).isModuleEnabled(address(this)); + if (!moduleEnabled) { + revert RecoveryModuleNotAuthorized(); + } + + // This check ensures GuardianStatus is correct and also implicitly that the + // account in email is a valid account + GuardianStorage memory guardianStorage = getGuardian(account, guardian); + if (guardianStorage.status != GuardianStatus.REQUESTED) { + revert InvalidGuardianStatus(guardianStorage.status, GuardianStatus.REQUESTED); + } + + guardiansStorage.updateGuardianStatus(account, guardian, GuardianStatus.ACCEPTED); + + emit GuardianAccepted(account, guardian); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* HANDLE RECOVERY */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Processes a recovery request for a given account. This is the third core function + * that must be called during the end-to-end recovery flow + * @dev Called once per guardian until the threshold is reached + * @param guardian The address of the guardian initiating the recovery + * @param templateIdx The index of the template used for the recovery request + * @param subjectParams An array of bytes containing the subject parameters + */ + function processRecovery( + address guardian, + uint256 templateIdx, + bytes[] memory subjectParams, + bytes32 + ) + internal + override + { + if (templateIdx != 0) { + revert InvalidTemplateIndex(); + } + if (subjectParams.length != 4) { + revert InvalidSubjectParams(); + } + + address account = abi.decode(subjectParams[0], (address)); + address oldOwner = abi.decode(subjectParams[1], (address)); + address newOwner = abi.decode(subjectParams[2], (address)); + // FIXME: recovery module address? + + bool moduleEnabled = ISafe(account).isModuleEnabled(address(this)); + + if (!moduleEnabled) { + revert RecoveryModuleNotAuthorized(); + } + + // This check ensures GuardianStatus is correct and also implicitly that the + // account in email is a valid account + GuardianStorage memory guardianStorage = getGuardian(account, guardian); + if (guardianStorage.status != GuardianStatus.ACCEPTED) { + revert InvalidGuardianStatus(guardianStorage.status, GuardianStatus.ACCEPTED); + } + bool isOwner = ISafe(account).isOwner(oldOwner); + if (!isOwner) { + revert InvalidOldOwner(); + } + if (newOwner == address(0)) { + revert InvalidNewOwner(); + } + + address previousOwnerInLinkedList = getPreviousOwnerInLinkedList(account, oldOwner); + bytes memory recoveryCallData = abi.encodeWithSignature( + "swapOwner(address,address,address)", previousOwnerInLinkedList, oldOwner, newOwner + ); + bytes32 calldataHash = keccak256(recoveryCallData); + + RecoveryRequest storage recoveryRequest = recoveryRequests[account]; + recoveryRequest.currentWeight += guardianStorage.weight; + + uint256 threshold = guardianConfigs[account].threshold; + if (recoveryRequest.currentWeight >= threshold) { + uint256 executeAfter = block.timestamp + recoveryConfigs[account].delay; + uint256 executeBefore = block.timestamp + recoveryConfigs[account].expiry; + + recoveryRequest.executeAfter = executeAfter; + recoveryRequest.executeBefore = executeBefore; + recoveryRequest.calldataHash = calldataHash; + + emit RecoveryProcessed(account, executeAfter, executeBefore); + } + } + + /** + * @notice Gets the previous owner in the Safe owners linked list that points to the + * owner passed into the function + * @param safe The Safe account to query + * @param oldOwner The owner address to get the previous owner for + * @return previousOwner The previous owner in the Safe owners linked list pointing to the owner + * passed in + */ + function getPreviousOwnerInLinkedList( + address safe, + address oldOwner + ) + internal + view + returns (address) + { + address[] memory owners = ISafe(safe).getOwners(); + uint256 length = owners.length; + + uint256 oldOwnerIndex; + for (uint256 i; i < length; i++) { + if (owners[i] == oldOwner) { + oldOwnerIndex = i; + break; + } + } + address sentinelOwner = address(0x1); + return oldOwnerIndex == 0 ? sentinelOwner : owners[oldOwnerIndex - 1]; + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* COMPLETE RECOVERY */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Completes the recovery process for a given account. This is the forth and final + * core function that must be called during the end-to-end recovery flow. Can be called by + * anyone. + * @dev Validates the recovery request by checking the total weight, that the delay has passed, + * and the request has not expired. Triggers the recovery module to perform the recovery. The + * recovery module trusts that this contract has validated the recovery attempt. This function + * deletes the recovery request but recovery config state is maintained so future recovery + * requests can be made without having to reconfigure everything + * @param account The address of the account for which the recovery is being completed + * @param recoveryCalldata The calldata that is passed to recover the validator + */ + function completeRecovery(address account, bytes memory recoveryCalldata) public override { + if (account == address(0)) { + revert InvalidAccountAddress(); + } + RecoveryRequest memory recoveryRequest = recoveryRequests[account]; + + uint256 threshold = guardianConfigs[account].threshold; + if (threshold == 0) { + revert NoRecoveryConfigured(); + } + + if (recoveryRequest.currentWeight < threshold) { + revert NotEnoughApprovals(); + } + + if (block.timestamp < recoveryRequest.executeAfter) { + revert DelayNotPassed(); + } + + if (block.timestamp >= recoveryRequest.executeBefore) { + revert RecoveryRequestExpired(); + } + + bytes32 calldataHash = keccak256(recoveryCalldata); + if (calldataHash != recoveryRequest.calldataHash) { + revert InvalidCalldataHash(); + } + + delete recoveryRequests[account]; + + ISafe(account).execTransactionFromModule(account, 0, recoveryCalldata, 0); + + emit RecoveryCompleted(account); + } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* CANCEL LOGIC */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Cancels the recovery request for the caller's account + * @dev Deletes the current recovery request associated with the caller's account + */ + function cancelRecovery() external virtual { + delete recoveryRequests[msg.sender]; + emit RecoveryCancelled(msg.sender); + } + + function deInitRecoveryFromModule(address account) external { } + + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* GUARDIAN LOGIC */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + + /** + * @notice Retrieves the guardian configuration for a given account + * @param account The address of the account for which the guardian configuration is being + * retrieved + * @return GuardianConfig The guardian configuration for the specified account + */ + function getGuardianConfig(address account) external view returns (GuardianConfig memory) { + return guardianConfigs[account]; + } + + /** + * @notice Retrieves the guardian storage details for a given guardian and account + * @param account The address of the account associated with the guardian + * @param guardian The address of the guardian + * @return GuardianStorage The guardian storage details for the specified guardian and account + */ + function getGuardian( + address account, + address guardian + ) + public + view + returns (GuardianStorage memory) + { + return guardiansStorage.getGuardianStorage(account, guardian); + } + + /** + * @notice Sets up guardians for a given account with specified weights and threshold + * @dev This function can only be called once and ensures the guardians, weights, and threshold + * are correctly configured + * @param account The address of the account for which guardians are being set up + * @param guardians An array of guardian addresses + * @param weights An array of weights corresponding to each guardian + * @param threshold The threshold weight required for guardians to approve recovery attempts + */ + function setupGuardians( + address account, + address[] memory guardians, + uint256[] memory weights, + uint256 threshold + ) + internal + { + guardianConfigs.setupGuardians(guardiansStorage, account, guardians, weights, threshold); + } + + /** + * @notice Adds a guardian for the caller's account with a specified weight + * @dev This function can only be called by the account associated with the guardian and only if + * no recovery is in process + * @param guardian The address of the guardian to be added + * @param weight The weight assigned to the guardian + */ + function addGuardian(address guardian, uint256 weight) external onlyWhenNotRecovering { + guardiansStorage.addGuardian(guardianConfigs, msg.sender, guardian, weight); + } + + /** + * @notice Removes a guardian for the caller's account + * @dev This function can only be called by the account associated with the guardian and only if + * no recovery is in process + * @param guardian The address of the guardian to be removed + */ + function removeGuardian(address guardian) external onlyWhenNotRecovering { + guardiansStorage.removeGuardian(guardianConfigs, msg.sender, guardian); + } + + /** + * @notice Changes the threshold for guardian approvals for the caller's account + * @dev This function can only be called by the account associated with the guardian config and + * only if no recovery is in process + * @param threshold The new threshold for guardian approvals + */ + function changeThreshold(uint256 threshold) external onlyWhenNotRecovering { + guardianConfigs.changeThreshold(msg.sender, threshold); + } +} diff --git a/test/integration/SafeRecovery/SafeNativeIntegrationBase.t.sol b/test/integration/SafeRecovery/SafeNativeIntegrationBase.t.sol new file mode 100644 index 0000000..3b181eb --- /dev/null +++ b/test/integration/SafeRecovery/SafeNativeIntegrationBase.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { console2 } from "forge-std/console2.sol"; + +import { ModuleKitHelpers } from "modulekit/ModuleKit.sol"; +import { MODULE_TYPE_EXECUTOR } from "modulekit/external/ERC7579.sol"; +import { EmailAuthMsg, EmailProof } from "ether-email-auth/packages/contracts/src/EmailAuth.sol"; +import { SubjectUtils } from "ether-email-auth/packages/contracts/src/libraries/SubjectUtils.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { Safe } from "@safe-global/safe-contracts/contracts/Safe.sol"; +import { SafeProxy } from "@safe-global/safe-contracts/contracts/proxies/SafeProxy.sol"; +import { SafeEmailRecoveryModule } from "src/modules/SafeEmailRecoveryModule.sol"; +import { EmailRecoveryManager } from "src/EmailRecoveryManager.sol"; +import { UniversalEmailRecoveryModule } from "src/modules/UniversalEmailRecoveryModule.sol"; +import { SafeRecoverySubjectHandler } from "src/handlers/SafeRecoverySubjectHandler.sol"; +import { IntegrationBase } from "../IntegrationBase.t.sol"; + +abstract contract SafeNativeIntegrationBase is IntegrationBase { + using ModuleKitHelpers for *; + using Strings for uint256; + using Strings for address; + + SafeEmailRecoveryModule safeEmailRecoveryModule; + Safe public safeSingleton; + Safe public safe; + address public safeAddress; + address public owner; + bytes isInstalledContext; + bytes4 functionSelector; + uint256 nullifierCount; + + /** + * Helper function to return if current account type is safe or not + */ + function isAccountTypeSafe() public returns (bool) { + string memory currentAccountType = vm.envOr("ACCOUNT_TYPE", string("")); + if (Strings.equal(currentAccountType, "SAFE")) { + return true; + } else { + return false; + } + } + + function setUp() public virtual override { + if (!isAccountTypeSafe()) { + return; + } + super.setUp(); + + safeEmailRecoveryModule = new SafeEmailRecoveryModule( + address(verifier), address(ecdsaOwnedDkimRegistry), address(emailAuthImpl) + ); + + safeSingleton = new Safe(); + SafeProxy safeProxy = new SafeProxy(address(safeSingleton)); + safe = Safe(payable(address(safeProxy))); + safeAddress = address(safe); + // safe4337Module = new Safe4337Module(entryPointAddress); + // safeModuleSetup = new SafeModuleSetup(); + + isInstalledContext = bytes("0"); + functionSelector = bytes4(keccak256(bytes("swapOwner(address,address,address)"))); + + // Compute guardian addresses + guardians1 = new address[](3); + guardians1[0] = safeEmailRecoveryModule.computeEmailAuthAddress(safeAddress, accountSalt1); + guardians1[1] = safeEmailRecoveryModule.computeEmailAuthAddress(safeAddress, accountSalt2); + guardians1[2] = safeEmailRecoveryModule.computeEmailAuthAddress(safeAddress, accountSalt3); + + address[] memory owners = new address[](1); + owner = owner1; + owners[0] = owner; + + safe.setup( + owners, + 1, + address(0), + bytes("0"), + address(0), + // address(safeModuleSetup), + // abi.encodeCall(SafeModuleSetup.enableModules, (modules)), + // address(safe4337Module), + address(0), + 0, + payable(address(0)) + ); + + vm.startPrank(safeAddress); + safe.enableModule(address(safeEmailRecoveryModule)); + vm.stopPrank(); + } + + function generateMockEmailProof( + string memory subject, + bytes32 nullifier, + bytes32 accountSalt + ) + public + view + returns (EmailProof memory) + { + EmailProof memory emailProof; + emailProof.domainName = "gmail.com"; + emailProof.publicKeyHash = bytes32( + vm.parseUint( + "6632353713085157925504008443078919716322386156160602218536961028046468237192" + ) + ); + emailProof.timestamp = block.timestamp; + emailProof.maskedSubject = subject; + emailProof.emailNullifier = nullifier; + emailProof.accountSalt = accountSalt; + emailProof.isCodeExist = true; + emailProof.proof = bytes("0"); + + return emailProof; + } + + function getAccountSaltForGuardian(address guardian) public returns (bytes32) { + if (guardian == guardians1[0]) { + return accountSalt1; + } + if (guardian == guardians1[1]) { + return accountSalt2; + } + if (guardian == guardians1[2]) { + return accountSalt3; + } + + revert("Invalid guardian address"); + } + + function generateNewNullifier() public returns (bytes32) { + return keccak256(abi.encode(nullifierCount++)); + } + + function acceptGuardian(address account, address guardian) public { + EmailAuthMsg memory emailAuthMsg = getAcceptanceEmailAuthMessage(account, guardian); + safeEmailRecoveryModule.handleAcceptance(emailAuthMsg, templateIdx); + } + + function getAcceptanceEmailAuthMessage( + address account, + address guardian + ) + public + returns (EmailAuthMsg memory) + { + string memory accountString = SubjectUtils.addressToChecksumHexString(account); + string memory subject = string.concat("Accept guardian request for ", accountString); + bytes32 nullifier = generateNewNullifier(); + bytes32 accountSalt = getAccountSaltForGuardian(guardian); + + EmailProof memory emailProof = generateMockEmailProof(subject, nullifier, accountSalt); + + bytes[] memory subjectParamsForAcceptance = new bytes[](1); + subjectParamsForAcceptance[0] = abi.encode(account); + return EmailAuthMsg({ + templateId: safeEmailRecoveryModule.computeAcceptanceTemplateId(templateIdx), + subjectParams: subjectParamsForAcceptance, + skipedSubjectPrefix: 0, + proof: emailProof + }); + } + + function handleRecovery( + address account, + address oldOwner, + address newOwner, + address guardian + ) + public + { + EmailAuthMsg memory emailAuthMsg = + getRecoveryEmailAuthMessage(account, oldOwner, newOwner, guardian); + safeEmailRecoveryModule.handleRecovery(emailAuthMsg, templateIdx); + } + + function getRecoveryEmailAuthMessage( + address account, + address oldOwner, + address newOwner, + address guardian + ) + public + returns (EmailAuthMsg memory) + { + string memory accountString = SubjectUtils.addressToChecksumHexString(account); + string memory oldOwnerString = SubjectUtils.addressToChecksumHexString(oldOwner); + string memory newOwnerString = SubjectUtils.addressToChecksumHexString(newOwner); + string memory recoveryModuleString = + SubjectUtils.addressToChecksumHexString(address(safeEmailRecoveryModule)); + + string memory subject = string.concat( + "Recover account ", + accountString, + " from old owner ", + oldOwnerString, + " to new owner ", + newOwnerString, + " using recovery module ", + recoveryModuleString + ); + bytes32 nullifier = generateNewNullifier(); + bytes32 accountSalt = getAccountSaltForGuardian(guardian); + + EmailProof memory emailProof = generateMockEmailProof(subject, nullifier, accountSalt); + + bytes[] memory subjectParamsForRecovery = new bytes[](4); + subjectParamsForRecovery[0] = abi.encode(account); + subjectParamsForRecovery[1] = abi.encode(oldOwner); + subjectParamsForRecovery[2] = abi.encode(newOwner); + subjectParamsForRecovery[3] = abi.encode(address(safeEmailRecoveryModule)); + + return EmailAuthMsg({ + templateId: safeEmailRecoveryModule.computeRecoveryTemplateId(templateIdx), + subjectParams: subjectParamsForRecovery, + skipedSubjectPrefix: 0, + proof: emailProof + }); + } +} diff --git a/test/integration/SafeRecovery/SafeRecoveryNativeModule.t.sol b/test/integration/SafeRecovery/SafeRecoveryNativeModule.t.sol new file mode 100644 index 0000000..8ed7046 --- /dev/null +++ b/test/integration/SafeRecovery/SafeRecoveryNativeModule.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import { console2 } from "forge-std/console2.sol"; +import { ModuleKitHelpers, ModuleKitUserOp } from "modulekit/ModuleKit.sol"; +import { MODULE_TYPE_EXECUTOR } from "erc7579/interfaces/IERC7579Module.sol"; +import { IERC7579Account } from "erc7579/interfaces/IERC7579Account.sol"; +import { Safe } from "@safe-global/safe-contracts/contracts/Safe.sol"; +import { SafeProxy } from "@safe-global/safe-contracts/contracts/proxies/SafeProxy.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { EmailAuthMsg } from "ether-email-auth/packages/contracts/src/EmailAuth.sol"; +import { SafeEmailRecoveryModule } from "src/modules/SafeEmailRecoveryModule.sol"; +import { IEmailRecoveryManager } from "src/interfaces/IEmailRecoveryManager.sol"; +import { GuardianStorage, GuardianStatus } from "src/libraries/EnumerableGuardianMap.sol"; +import { SafeNativeIntegrationBase } from "./SafeNativeIntegrationBase.t.sol"; + +contract SafeRecoveryNativeModule_Integration_Test is SafeNativeIntegrationBase { + function setUp() public override { + super.setUp(); + } + + function testIntegration_AccountRecovery() public { + bool moduleEnabled = safe.isModuleEnabled(address(safeEmailRecoveryModule)); + + address newOwner = owner2; + + // Configure recovery + vm.startPrank(safeAddress); + safeEmailRecoveryModule.configureRecovery( + guardians1, guardianWeights, threshold, delay, expiry + ); + vm.stopPrank(); + + bytes memory recoveryCalldata = abi.encodeWithSignature( + "swapOwner(address,address,address)", address(1), owner, newOwner + ); + bytes32 calldataHash = keccak256(recoveryCalldata); + + bytes[] memory subjectParamsForRecovery = new bytes[](4); + subjectParamsForRecovery[0] = abi.encode(safeAddress); + subjectParamsForRecovery[1] = abi.encode(owner); + subjectParamsForRecovery[2] = abi.encode(newOwner); + subjectParamsForRecovery[3] = abi.encode(address(safeEmailRecoveryModule)); + + // Accept guardian + EmailAuthMsg memory emailAuthMsg = getAcceptanceEmailAuthMessage(safeAddress, guardians1[0]); + safeEmailRecoveryModule.handleAcceptance(emailAuthMsg, templateIdx); + GuardianStorage memory guardianStorage1 = + safeEmailRecoveryModule.getGuardian(safeAddress, guardians1[0]); + assertEq(uint256(guardianStorage1.status), uint256(GuardianStatus.ACCEPTED)); + assertEq(guardianStorage1.weight, uint256(1)); + + // Accept guardian + emailAuthMsg = getAcceptanceEmailAuthMessage(safeAddress, guardians1[1]); + safeEmailRecoveryModule.handleAcceptance(emailAuthMsg, templateIdx); + GuardianStorage memory guardianStorage2 = + safeEmailRecoveryModule.getGuardian(safeAddress, guardians1[1]); + assertEq(uint256(guardianStorage2.status), uint256(GuardianStatus.ACCEPTED)); + assertEq(guardianStorage2.weight, uint256(2)); + + // Time travel so that EmailAuth timestamp is valid + vm.warp(12 seconds); + + // handle recovery request for guardian 1 + emailAuthMsg = getRecoveryEmailAuthMessage(safeAddress, owner, newOwner, guardians1[0]); + safeEmailRecoveryModule.handleRecovery(emailAuthMsg, templateIdx); + IEmailRecoveryManager.RecoveryRequest memory recoveryRequest = + safeEmailRecoveryModule.getRecoveryRequest(safeAddress); + assertEq(recoveryRequest.currentWeight, 1); + + // handle recovery request for guardian 2 + uint256 executeAfter = block.timestamp + delay; + uint256 executeBefore = block.timestamp + expiry; + emailAuthMsg = getRecoveryEmailAuthMessage(safeAddress, owner, newOwner, guardians1[1]); + safeEmailRecoveryModule.handleRecovery(emailAuthMsg, templateIdx); + recoveryRequest = safeEmailRecoveryModule.getRecoveryRequest(safeAddress); + assertEq(recoveryRequest.executeAfter, executeAfter); + assertEq(recoveryRequest.executeBefore, executeBefore); + assertEq(recoveryRequest.currentWeight, 3); + + vm.warp(block.timestamp + delay); + + // Complete recovery + safeEmailRecoveryModule.completeRecovery(safeAddress, recoveryCalldata); + + recoveryRequest = safeEmailRecoveryModule.getRecoveryRequest(safeAddress); + assertEq(recoveryRequest.executeAfter, 0); + assertEq(recoveryRequest.executeBefore, 0); + assertEq(recoveryRequest.currentWeight, 0); + + vm.prank(safeAddress); + bool isOwner = Safe(payable(safeAddress)).isOwner(newOwner); + assertTrue(isOwner); + + bool oldOwnerIsOwner = Safe(payable(safeAddress)).isOwner(owner); + assertFalse(oldOwnerIsOwner); + } +}