├── .github ├── actions │ └── action.yml └── workflows │ ├── integration.yml │ ├── lint.yml │ ├── multichain.yml │ ├── size.yml │ └── unit.yml ├── .gitignore ├── .gitmodules ├── Architecture.png ├── LICENSE ├── README.md ├── addresses ├── 1.json ├── 10.json ├── 11155420.json ├── 8453.json └── 84532.json ├── audit └── Kleidi-Recon-Report.pdf ├── certora ├── confs │ └── Timelock.conf ├── mutation │ └── TimelockConfig.json └── specs │ ├── ITimelock.spec │ └── Timelock.spec ├── docs ├── ACL.md ├── ADERYN.md ├── AUDIT_LOG.md ├── AUDIT_SCOPE.md ├── CALLDATA_WHITELISTING.md ├── DEPLOYMENT.md ├── EDGECASES.md ├── INVARIANTS.md ├── KNOWN_ISSUES.md ├── MORPHO_EXAMPLE.md ├── REQUIREMENTS.md ├── SLITHER.md └── TESTING.md ├── foundry.toml ├── remappings.txt ├── run-timelock-mutation.sh ├── src ├── BytesHelper.sol ├── ConfigurablePause.sol ├── Guard.sol ├── InstanceDeployer.sol ├── RecoverySpell.sol ├── RecoverySpellFactory.sol ├── Timelock.sol ├── TimelockFactory.sol ├── deploy │ └── SystemDeploy.s.sol ├── interface │ ├── CErc20Interface.sol │ ├── CEtherInterface.sol │ ├── IMorpho.sol │ ├── IMulticall3.sol │ └── WETH9.sol ├── utils │ ├── Constants.sol │ └── Create2Helper.sol └── views │ └── AddressCalculation.sol └── test ├── integration ├── AddressCalculation.t.sol ├── Deployment.t.sol ├── InstanceDeployer.t.sol ├── RecoverySpells.t.sol ├── SocialRecovery.t.sol └── System.t.sol ├── mock ├── MockERC1155.sol ├── MockERC721.sol ├── MockLending.sol ├── MockReentrancyExecutor.sol ├── MockSafe.sol └── MockTwoParams.sol ├── unit ├── BytesHelper.t.sol ├── CalldataList.t.sol ├── Guard.t.sol ├── RecoverySpell.t.sol ├── RecoverySpellFactory.t.sol ├── Timelock.t.sol ├── TimelockFactory.t.sol ├── TimelockPause.t.sol └── TimelockReceiving.t.sol └── utils ├── CallHelper.t.sol ├── NestedArrayHelper.sol ├── SigHelper.sol ├── SystemIntegrationFixture.sol └── TimelockUnitFixture.sol /.github/actions/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Environment" 2 | description: "Set up the pre-compiled environment" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Checkout the repository 8 | uses: actions/checkout@v2 9 | with: 10 | submodules: recursive 11 | 12 | - name: Install Foundry 13 | uses: foundry-rs/foundry-toolchain@v1 14 | with: 15 | version: nightly 16 | 17 | - name: Clean Contracts 18 | run: forge clean 19 | shell: bash 20 | 21 | - name: Compile Contracts 22 | run: forge build 23 | shell: bash 24 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Foundry integration tests 2 | 3 | on: [pull_request] 4 | 5 | env: 6 | ETH_RPC_URL: ${{secrets.ETH_RPC_URL}} 7 | 8 | jobs: 9 | integration-tests: 10 | name: integration-tests 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | with: 17 | submodules: recursive 18 | 19 | - name: Setup Environment 20 | uses: ./.github/actions 21 | 22 | - name: Integration Test Contracts 23 | run: time forge test --mc IntegrationTest -vvv --fork-url $ETH_RPC_URL --libraries src/BytesHelper.sol:BytesHelper:0x146dfd96da039fde3b58d5964fef8e8357df2028 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Forge Linter 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: "Check out the repo" 10 | uses: actions/checkout@v3 11 | with: 12 | submodules: recursive 13 | 14 | - name: Setup Environment 15 | uses: ./.github/actions 16 | 17 | - name: Run linter and check for errors 18 | id: lint 19 | run: forge fmt --check 20 | -------------------------------------------------------------------------------- /.github/workflows/multichain.yml: -------------------------------------------------------------------------------- 1 | name: Foundry Multichain tests 2 | 3 | on: [pull_request] 4 | 5 | env: 6 | ETH_RPC_URL: ${{secrets.ETH_RPC_URL}} 7 | 8 | jobs: 9 | multichain-tests: 10 | name: multichain-tests 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | with: 17 | submodules: recursive 18 | 19 | - name: Setup Environment 20 | uses: ./.github/actions 21 | 22 | - name: Multichain Tests 23 | run: time forge test --mc DeploymentMultichainTest -vvv 24 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: Contract Size Check 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: "Check out the repo" 10 | uses: actions/checkout@v3 11 | with: 12 | submodules: recursive 13 | 14 | - name: Setup Environment 15 | uses: ./.github/actions 16 | 17 | - name: Run contract size check 18 | id: lint 19 | run: forge build --sizes 20 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: Foundry unit tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | unit-tests: 7 | name: unit-tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | with: 14 | submodules: recursive 15 | 16 | - name: Setup Environment 17 | uses: ./.github/actions 18 | 19 | - name: Unit Test Contracts 20 | run: time forge test --mc UnitTest -vvv 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | #coverage 11 | coverage/ 12 | 13 | # Dotenv file 14 | .env 15 | 16 | lcov.info 17 | 18 | broadcast/* 19 | 20 | .certora_internal/ 21 | 22 | gambit_out_Timelock/ 23 | 24 | MutationTestOutput/ 25 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts"] 5 | path = lib/openzeppelin-contracts 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 7 | [submodule "lib/safe-smart-account"] 8 | path = lib/safe-smart-account 9 | url = https://github.com/safe-global/safe-smart-account 10 | [submodule "lib/forge-proposal-simulator"] 11 | path = lib/forge-proposal-simulator 12 | url = https://github.com/solidity-labs-io/forge-proposal-simulator 13 | -------------------------------------------------------------------------------- /Architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solidity-labs-io/kleidi/892115ed5b0e802f6ef15cd6308a25b372189345/Architecture.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Business Source License 1.1 2 | 3 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. 4 | "Business Source License" is a trademark of MariaDB Corporation Ab. 5 | 6 | ----------------------------------------------------------------------------- 7 | 8 | Parameters 9 | 10 | Licensor: Solidity Labs 11 | 12 | Licensed Work: Timelock Wallet 13 | The Licensed Work is (c) 2024 Solidity Labs LLC 14 | 15 | Change Date: Unspecified. 16 | 17 | Change License: GNU General Public License v2.0 or later 18 | 19 | ----------------------------------------------------------------------------- 20 | 21 | Terms 22 | 23 | The Licensor hereby grants you the right to copy, modify, create derivative 24 | works, redistribute, and make non-production use of the Licensed Work. The 25 | Licensor may make an Additional Use Grant, above, permitting limited 26 | production use. 27 | 28 | Effective on the Change Date, or the fourth anniversary of the first publicly 29 | available distribution of a specific version of the Licensed Work under this 30 | License, whichever comes first, the Licensor hereby grants you rights under 31 | the terms of the Change License, and the rights granted in the paragraph 32 | above terminate. 33 | 34 | If your use of the Licensed Work does not comply with the requirements 35 | currently in effect as described in this License, you must purchase a 36 | commercial license from the Licensor, its affiliated entities, or authorized 37 | resellers, or you must refrain from using the Licensed Work. 38 | 39 | All copies of the original and modified Licensed Work, and derivative works 40 | of the Licensed Work, are subject to this License. This License applies 41 | separately for each version of the Licensed Work and the Change Date may vary 42 | for each version of the Licensed Work released by Licensor. 43 | 44 | You must conspicuously display this License on each original or modified copy 45 | of the Licensed Work. If you receive the Licensed Work in original or 46 | modified form from a third party, the terms and conditions set forth in this 47 | License apply to your use of that work. 48 | 49 | Any use of the Licensed Work in violation of this License will automatically 50 | terminate your rights under this License for the current and all other 51 | versions of the Licensed Work. 52 | 53 | This License does not grant you any right in any trademark or logo of 54 | Licensor or its affiliates (provided that you may use a trademark or logo of 55 | Licensor as expressly required by this License). 56 | 57 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON 58 | AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, 59 | EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 60 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 61 | TITLE. 62 | 63 | MariaDB hereby grants you permission to use this License’s text to license 64 | your works, and to refer to it using the trademark "Business Source License", 65 | as long as you comply with the Covenants of Licensor below. 66 | 67 | ----------------------------------------------------------------------------- 68 | 69 | Covenants of Licensor 70 | 71 | In consideration of the right to use this License’s text and the "Business 72 | Source License" name and trademark, Licensor covenants to MariaDB, and to all 73 | other recipients of the licensed work to be provided by Licensor: 74 | 75 | 1. To specify as the Change License the GPL Version 2.0 or any later version, 76 | or a license that is compatible with GPL Version 2.0 or a later version, 77 | where "compatible" means that software provided under the Change License can 78 | be included in a program with software provided under GPL Version 2.0 or a 79 | later version. Licensor may specify additional Change Licenses without 80 | limitation. 81 | 82 | 2. To either: (a) specify an additional grant of rights to use that does not 83 | impose any additional restriction on the right granted in this License, as 84 | the Additional Use Grant; or (b) insert the text "None". 85 | 86 | 3. To specify a Change Date. 87 | 88 | 4. Not to modify this License in any other way. 89 | 90 | ----------------------------------------------------------------------------- 91 | 92 | Notice 93 | 94 | The Business Source License (this document, or the "License") is not an Open 95 | Source license. However, the Licensed Work will eventually be made available 96 | under an Open Source License, as stated in this License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kleidi 2 | 3 | The Kleidi Wallet is a collection of smart contracts that can be used to create a full self-custody wallet system for DeFi users. The protocol is opinionated and is designed to be used with Gnosis Safe multisigs, a timelock, guard contract, and recovery spells. It enables users to access DeFi yields and protocols, pull funds in case of emergency, and recover funds in case of lost keys either through social recovery or predefined backups. 4 | 5 | ## Design Principles 6 | 7 | - **Security**: The protocol is designed by the world class Smart Contract engineering team at Solidity Labs. It has had components formally verified, and has been audited twice with no critical or high issues discovered. It is designed to be used with Gnosis Safe multisigs, which are battle-tested and have secured tens of billions of dollars in assets. See our internal audit log [here](docs/AUDIT_LOG.md). 8 | - **Flexibility**: The protocol is designed to be flexible and can be used in a variety of ways. It can be used by DAOs, individuals, and other entities. Recovery spells allow users the ability to create custom recovery logic or flows to protect their assets. 9 | - **Self Reliance**: This smart contract system is designed to enable a self custody system that does not require trusted third parties. Funds can be securely managed by a single user in this system setup. Users can recover their funds without needing to rely on a trusted third party, though they can choose to use a social recovery system if they wish. 10 | - **Wrench Resistant**: One of the guiding principles of this system is to be resistant to $5 wrench attacks. Even if an attacker is able to coerce a user into signing a transaction, the system of recovery spells and guardians slows down an attacker trying to steal funds. With a 30 day timelock as the delay on new transactions, an attacker would need to kidnap a user for a month and remain undetected for the entire period in order to steal funds. 11 | - **Defense in Depth**: The system is designed with multiple layers of security. The timelock prevents transactions from being executed immediately. Recovery spells can be used to create custom recovery logic for a multisig. The recovery spell could either completely rotate the multisig signer set, or contain more complex logic to recover funds in case of lost keys. 12 | 13 | ## Architecture 14 | ![](Architecture.png) 15 | 16 | Each system component works together to ensure a user cannot be coerced into signing actions they do not want to take. 17 | 18 | - **[Guard](src/Guard.sol)**: This contract restricts the the multisig by disallowing delegate and self calls. This removes multicall functionality from the Gnosis Safe while enabled. Additionally, it stops owners from being rotated out of the multisig (except by modules), stops additional modules from being added or removed during a transaction, and prevents the guard from being disabled by a transaction. With these checks in place, it is impossible to remove the guard, rotate signers, or add new modules to the multisig without a timelocked transaction passing. 19 | - **[Timelock](src/Timelock.sol)**: This contract holds all funds and requires a delay before a transaction can be executed. This delay can be set to any time period between 1 and 30 days. This delay prevents an attacker from immediately executing a transaction after coercing a user into signing a transaction. The timelock can whitelist contract calls and calldata, this allows hot signers to execute interactions with other smart contracts as long as the contract, function signature, and certain parts of the calldata are whitelisted. 20 | - **[Recovery Spells](src/RecoverySpell.sol)**: Recovery spells are custom recovery mechanisms that can be used to recover funds in case of lost keys. They can be used to rotate the signers of a multisig, or to create other custom recovery logic. This allows users to craft custom recovery flows that can be used to recover funds in case of lost keys. Recovery spells can be used to create a recovery mechanism that is resistant to $5 wrench attacks, and can be used to recover funds in case of lost keys. A social recovery recovery spell could allow the recovery members to rotate the signers of the multisig after a predefined timelock. This would allow the multisig owner to cancel the recovery spell if they still had access to their keys, but would allow the recovery members to rotate the signers if the multisig owner lost their keys. 21 | 22 | ## Usage 23 | 24 | The only safe way to create a new wallet is through the [InstanceDeployer](src/InstanceDeployer.sol) contract. Wallet contracts created outside of the InstanceDeployer should be assumed to be unsafe. The InstanceDeployer deploys a system instance atomically and deterministically with the desired configuration. The configuration will include the timelock delay, guardian, pause duration, whitelisted targets, calldatas, and the users' Gnosis Safe as the owner. The Instance Deployer contract will have no permission in the deployed contracts once the deployment transaction is completed. 25 | 26 | Instance deployer will 27 | 28 | 1. Deploy a timelock contract with the desired delay, guardian, pause duration, whitelisted targets, calldatas, with the users' Gnosis Safe as the owner. 29 | 2. Execute a transaction through the Gnosis Safe to perform the following actions: 30 | - initialize the timelock with the specified protocols and hot signers 31 | - add the Guard to the Safe 32 | - add the Timelock as a Safe module 33 | - add the recovery spells to the Safe as modules 34 | - remove the InstanceDeployer as an owner of the Safe 35 | - add the specified owners to the Safe 36 | - set the specified Safe threshold 37 | 38 | ## Edge Cases 39 | 40 | - If the timelock is removed as a module from the Safe before the Guard is disabled, there will be no way to rotate the signers of the Safe, add new modules, or remove modules. 41 | 42 | 43 | ### Build 44 | 45 | ```shell 46 | forge build 47 | ``` 48 | 49 | ### Test 50 | 51 | ```shell 52 | forge test -vvv 53 | ``` 54 | 55 | ### Testing 56 | 57 | #### Unit Testing 58 | 59 | ``` 60 | forge test --mc UnitTest -vvv 61 | ``` 62 | 63 | #### Integration Testing 64 | 65 | ``` 66 | forge test --mc IntegrationTest -vvv --fork-url $ETH_RPC_URL --fork-block-number 20515328 67 | ``` 68 | 69 | ### Coverage 70 | 71 | 72 | #### Unit Test Coverage 73 | 74 | ```shell 75 | forge coverage --mc UnitTest --report lcov 76 | ``` 77 | 78 | #### Unit & Integration Test Coverage 79 | 80 | ```shell 81 | forge coverage --report summary --report lcov --fork-url $ETH_RPC_URL --fork-block-number 20515328 82 | ``` -------------------------------------------------------------------------------- /addresses/1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "addr": "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", 4 | "name": "SAFE_FACTORY", 5 | "isContract": true 6 | }, 7 | { 8 | "addr": "0x29B28B0Ff5B6B26448F3Ac02Cd209539626D96Ab", 9 | "name": "DEPLOYER_EOA", 10 | "isContract": false 11 | }, 12 | { 13 | "addr": "0xcA11bde05977b3631167028862bE2a173976CA11", 14 | "name": "MULTICALL3", 15 | "isContract": true 16 | }, 17 | { 18 | "addr": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", 19 | "name": "MORPHO_BLUE", 20 | "isContract": true 21 | }, 22 | { 23 | "addr": "0x4c9EDD5852cd905f086C759E8383e09bff1E68B3", 24 | "name": "ETHENA_USD", 25 | "isContract": true 26 | }, 27 | { 28 | "addr": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 29 | "name": "SAFE_LOGIC", 30 | "isContract": true 31 | }, 32 | { 33 | "addr": "0x6B175474E89094C44Da98b954EedeAC495271d0F", 34 | "name": "DAI", 35 | "isContract": true 36 | }, 37 | { 38 | "addr": "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC", 39 | "name": "MORPHO_BLUE_IRM", 40 | "isContract": true 41 | }, 42 | { 43 | "addr": "0xaE4750d0813B5E37A51f7629beedd72AF1f9cA35", 44 | "name": "MORPHO_BLUE_EUSD_DAI_ORACLE", 45 | "isContract": true 46 | }, 47 | { 48 | "addr": "0x5d3a536e4d6dbd6114cc1ead35777bab948e3643", 49 | "name": "C_DAI", 50 | "isContract": true 51 | }, 52 | { 53 | "addr": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", 54 | "name": "WBTC", 55 | "isContract": true 56 | }, 57 | { 58 | "addr": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 59 | "name": "WETH", 60 | "isContract": true 61 | }, 62 | { 63 | "addr": "0xc29B3Bc033640baE31ca53F8a0Eb892AdF68e663", 64 | "name": "MORPHO_BLUE_WBTC_WETH_ORACLE", 65 | "isContract": true 66 | }, 67 | { 68 | "addr": "0x4Ddc2D193948926D02f9B1fE9e1daa0718270ED5", 69 | "name": "C_ETHER", 70 | "isContract": true 71 | }, 72 | { 73 | "addr": "0xCe90BA68BbcdCCe9aed1fCDDcb114d1DCdBc68C9", 74 | "isContract": true, 75 | "name": "TIMELOCK_FACTORY" 76 | }, 77 | { 78 | "addr": "0x56b6d03b995022A612aF6a212C74902f233F52Cc", 79 | "isContract": true, 80 | "name": "RECOVERY_SPELL_FACTORY" 81 | }, 82 | { 83 | "addr": "0xFE49DD6d0CD41C4EC8F151C79f2d4019f5C5AD18", 84 | "isContract": true, 85 | "name": "GUARD" 86 | }, 87 | { 88 | "addr": "0xFF28751f5E56E7A262EC2724c80dE8b7d7E4a3C3", 89 | "isContract": true, 90 | "name": "INSTANCE_DEPLOYER" 91 | }, 92 | { 93 | "addr": "0x508CF523938178048BbFD8191eAD4ECE14855e7F", 94 | "isContract": true, 95 | "name": "ADDRESS_CALCULATION" 96 | }, 97 | { 98 | "addr": "0x146dfd96Da039FDE3B58D5964feF8E8357df2028", 99 | "isContract": true, 100 | "name": "BYTES_HELPER" 101 | } 102 | ] -------------------------------------------------------------------------------- /addresses/10.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "addr": "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67", 4 | "name": "SAFE_FACTORY", 5 | "isContract": true 6 | }, 7 | { 8 | "addr": "0x29B28B0Ff5B6B26448F3Ac02Cd209539626D96Ab", 9 | "name": "DEPLOYER_EOA", 10 | "isContract": false 11 | }, 12 | { 13 | "addr": "0xcA11bde05977b3631167028862bE2a173976CA11", 14 | "name": "MULTICALL3", 15 | "isContract": true 16 | }, 17 | { 18 | "addr": "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", 19 | "name": "SAFE_LOGIC", 20 | "isContract": true 21 | }, 22 | { 23 | "addr": "0x146dfd96da039fde3b58d5964fef8e8357df2028", 24 | "name": "BYTES_HELPER", 25 | "isContract": true 26 | }, 27 | { 28 | "addr": "0xCe90BA68BbcdCCe9aed1fCDDcb114d1DCdBc68C9", 29 | "isContract": true, 30 | "name": "TIMELOCK_FACTORY" 31 | }, 32 | { 33 | "addr": "0x56b6d03b995022A612aF6a212C74902f233F52Cc", 34 | "isContract": true, 35 | "name": "RECOVERY_SPELL_FACTORY" 36 | }, 37 | { 38 | "addr": "0xFE49DD6d0CD41C4EC8F151C79f2d4019f5C5AD18", 39 | "isContract": true, 40 | "name": "GUARD" 41 | }, 42 | { 43 | "addr": "0xE138136bFF8c6A9337805DE19177E3b29fef2783", 44 | "isContract": true, 45 | "name": "INSTANCE_DEPLOYER" 46 | }, 47 | { 48 | "addr": "0xd1db2c4A9d2BEBd56d42E59F2d90F4136164faD6", 49 | "isContract": true, 50 | "name": "ADDRESS_CALCULATION" 51 | } 52 | ] -------------------------------------------------------------------------------- /addresses/11155420.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "addr": "0x29b28b0ff5b6b26448f3ac02cd209539626d96ab", 4 | "name": "DEPLOYER_EOA", 5 | "isContract": false 6 | }, 7 | { 8 | "addr": "0xcA11bde05977b3631167028862bE2a173976CA11", 9 | "name": "MULTICALL3", 10 | "isContract": true 11 | }, 12 | { 13 | "addr": "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", 14 | "name": "SAFE_FACTORY", 15 | "isContract": true 16 | }, 17 | { 18 | "addr": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 19 | "name": "SAFE_LOGIC", 20 | "isContract": true 21 | }, 22 | { 23 | "addr": "0xE4BaC70b7Ac747F5F4Fc54906835aB4e21C5cC67", 24 | "isContract": true, 25 | "name": "TIMELOCK_FACTORY" 26 | }, 27 | { 28 | "addr": "0xD961EeAdB5eE0cf47d3929E2Ef9FE3100BAFf4f7", 29 | "isContract": true, 30 | "name": "RECOVERY_SPELL_FACTORY" 31 | }, 32 | { 33 | "addr": "0x762471De89Afaaa1A6cC86881B151CaC76eCaF1e", 34 | "isContract": true, 35 | "name": "GUARD" 36 | }, 37 | { 38 | "addr": "0x0A93F5Ca04F8E5CFd4FD94B19d9ac246353E92Ee", 39 | "isContract": true, 40 | "name": "INSTANCE_DEPLOYER" 41 | }, 42 | { 43 | "addr": "0xc096158eD9966E68DC011b8ce5392de4D07C1916", 44 | "isContract": true, 45 | "name": "ADDRESS_CALCULATION" 46 | } 47 | ] -------------------------------------------------------------------------------- /addresses/8453.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "addr": "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67", 4 | "name": "SAFE_FACTORY", 5 | "isContract": true 6 | }, 7 | { 8 | "addr": "0x29B28B0Ff5B6B26448F3Ac02Cd209539626D96Ab", 9 | "name": "DEPLOYER_EOA", 10 | "isContract": false 11 | }, 12 | { 13 | "addr": "0xcA11bde05977b3631167028862bE2a173976CA11", 14 | "name": "MULTICALL3", 15 | "isContract": true 16 | }, 17 | { 18 | "addr": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", 19 | "name": "MORPHO_BLUE", 20 | "isContract": true 21 | }, 22 | { 23 | "addr": "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", 24 | "name": "SAFE_LOGIC", 25 | "isContract": true 26 | }, 27 | { 28 | "addr": "0xE138136bFF8c6A9337805DE19177E3b29fef2783", 29 | "isContract": true, 30 | "name": "INSTANCE_DEPLOYER" 31 | }, 32 | { 33 | "addr": "0xd1db2c4A9d2BEBd56d42E59F2d90F4136164faD6", 34 | "isContract": true, 35 | "name": "ADDRESS_CALCULATION" 36 | }, 37 | { 38 | "addr": "0xCe90BA68BbcdCCe9aed1fCDDcb114d1DCdBc68C9", 39 | "isContract": true, 40 | "name": "TIMELOCK_FACTORY" 41 | }, 42 | { 43 | "addr": "0x56b6d03b995022A612aF6a212C74902f233F52Cc", 44 | "isContract": true, 45 | "name": "RECOVERY_SPELL_FACTORY" 46 | }, 47 | { 48 | "addr": "0xFE49DD6d0CD41C4EC8F151C79f2d4019f5C5AD18", 49 | "isContract": true, 50 | "name": "GUARD" 51 | }, 52 | { 53 | "addr": "0x146dfd96Da039FDE3B58D5964feF8E8357df2028", 54 | "isContract": true, 55 | "name": "BYTES_HELPER" 56 | } 57 | ] -------------------------------------------------------------------------------- /addresses/84532.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "addr": "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", 4 | "name": "SAFE_FACTORY", 5 | "isContract": true 6 | }, 7 | { 8 | "addr": "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 9 | "name": "SAFE_LOGIC", 10 | "isContract": true 11 | }, 12 | { 13 | "addr": "0xcA11bde05977b3631167028862bE2a173976CA11", 14 | "name": "MULTICALL3", 15 | "isContract": true 16 | }, 17 | { 18 | "addr": "0x29b28b0ff5b6b26448f3ac02cd209539626d96ab", 19 | "name": "DEPLOYER_EOA", 20 | "isContract": false 21 | }, 22 | { 23 | "addr": "0xE4BaC70b7Ac747F5F4Fc54906835aB4e21C5cC67", 24 | "isContract": true, 25 | "name": "TIMELOCK_FACTORY" 26 | }, 27 | { 28 | "addr": "0xD961EeAdB5eE0cf47d3929E2Ef9FE3100BAFf4f7", 29 | "isContract": true, 30 | "name": "RECOVERY_SPELL_FACTORY" 31 | }, 32 | { 33 | "addr": "0x762471De89Afaaa1A6cC86881B151CaC76eCaF1e", 34 | "isContract": true, 35 | "name": "GUARD" 36 | }, 37 | { 38 | "addr": "0x0A93F5Ca04F8E5CFd4FD94B19d9ac246353E92Ee", 39 | "isContract": true, 40 | "name": "INSTANCE_DEPLOYER" 41 | }, 42 | { 43 | "addr": "0xc096158eD9966E68DC011b8ce5392de4D07C1916", 44 | "isContract": true, 45 | "name": "ADDRESS_CALCULATION" 46 | } 47 | ] -------------------------------------------------------------------------------- /audit/Kleidi-Recon-Report.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solidity-labs-io/kleidi/892115ed5b0e802f6ef15cd6308a25b372189345/audit/Kleidi-Recon-Report.pdf -------------------------------------------------------------------------------- /certora/confs/Timelock.conf: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "src/Timelock.sol" 4 | ], 5 | "verify": "Timelock:certora/specs/Timelock.spec", 6 | "send_only": true, 7 | "optimistic_loop": true, 8 | "solc": "solc", 9 | "msg": "Timelock Verification", 10 | "rule_sanity": "basic", 11 | "optimistic_hashing": true, 12 | "packages": [ 13 | "@forge-std/=lib/forge-std/src/", 14 | "@openzeppelin-contracts/=lib/openzeppelin-contracts/", 15 | "@openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", 16 | "@forge-proposal-simulator=lib/forge-proposal-simulator/", 17 | "@safe/=lib/safe-smart-account/contracts/", 18 | "@src=src/" 19 | ] 20 | } -------------------------------------------------------------------------------- /certora/mutation/TimelockConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "filename": "../../src/Timelock.sol", 3 | "sourceroot": "../..", 4 | "solc_remappings": [ 5 | "@openzeppelin-contracts=../../lib/openzeppelin-contracts/", 6 | "@safe=../../lib/safe-smart-account/contracts/", 7 | "@src/=../../src/", 8 | "@forge-std/=../../lib/forge-std/src/", 9 | "@interface/=../../src/interface/", 10 | "@forge-proposal-simulator=../../lib/forge-proposal-simulator/" 11 | ], 12 | "outdir": "../../gambit_out_Timelock" 13 | } -------------------------------------------------------------------------------- /certora/specs/ITimelock.spec: -------------------------------------------------------------------------------- 1 | methods { 2 | function addCalldataCheck(address,bytes4,uint16,uint16,bytes[]) external ; 3 | function addCalldataChecks(address[],bytes4[],uint16[],uint16[],bytes[][]) external; 4 | function removeCalldataCheck(address,bytes4,uint256) external ; 5 | function removeAllCalldataChecks(address[],bytes4[]) external ; 6 | function getCalldataChecks(address,bytes4) external returns (Timelock.IndexData[]) envfree; 7 | function getRoleMemberCount(bytes32) external returns (uint256) envfree; 8 | function DEFAULT_ADMIN_ROLE() external returns (bytes32) envfree; 9 | function expirationPeriod() external returns (uint256) envfree; 10 | function isOperationReady(bytes32) external returns (bool) ; 11 | function HOT_SIGNER_ROLE() external returns (bytes32) envfree; 12 | function MAX_PAUSE_DURATION() external returns (uint256) envfree; 13 | function getAllProposals() external returns (bytes32[]) envfree; 14 | function pauseStartTime() external returns (uint128) envfree; 15 | function pauseDuration() external returns (uint128) envfree; 16 | function pauseGuardian() external returns (address) envfree; 17 | function isOperation(bytes32) external returns (bool) envfree; 18 | function timestamps(bytes32) external returns (uint256) envfree; 19 | function minDelay() external returns (uint256) envfree; 20 | function paused() external returns (bool) ; 21 | function pause() external returns (bool) ; 22 | function safe() external returns (address) envfree; 23 | function hasRole(bytes32,address) external returns (bool) envfree; 24 | function checkCalldata(address,bytes) external envfree; 25 | function revokeHotSigner(address) external ; 26 | function cleanup(bytes32) external ; 27 | function cancel(bytes32) external ; 28 | function atIndex(uint256) external returns (bytes32) envfree; 29 | function positionOf(bytes32) external returns (uint256) envfree; 30 | 31 | /// proposal creation and execution 32 | function hashOperationBatch(address[],uint256[],bytes[],bytes32) external returns (bytes32) envfree; 33 | function hashOperation(address,uint256,bytes,bytes32) external returns (bytes32) envfree; 34 | 35 | function scheduleBatch(address[],uint256[],bytes[],bytes32,uint256) external ; 36 | function executeBatch(address[],uint256[],bytes[],bytes32) external ; 37 | 38 | function schedule(address,uint256,bytes,bytes32,uint256) external ; 39 | function execute(address,uint256,bytes,bytes32) external ; 40 | } 41 | 42 | definition oneDay() returns uint256 = 84600; 43 | definition oneMonth() returns uint256 = 2592000; 44 | definition timestampMax() returns uint256 = 2 ^ 128 - 1; 45 | definition doneTimestamp() returns uint256 = 1; 46 | definition uintMax() returns uint256 = 2 ^ 256 - 1; 47 | -------------------------------------------------------------------------------- /docs/ACL.md: -------------------------------------------------------------------------------- 1 | # Access Controls 2 | 3 | View all modifiers 4 | 5 | command: 6 | ``` 7 | slither src/Timelock.sol --print modifiers --solc-remaps '@openzeppelin-contracts/=lib/openzeppelin-contracts/ @safe/=lib/safe-smart-account/contracts/ @src/=src/ @interface/=src/interface/' 8 | ``` 9 | 10 | output: 11 | ``` 12 | Contract Timelock 13 | +-------------------------------------+-------------------------------+---------------------+ 14 | | Function | Modifiers | State Change | 15 | +-------------------------------------+-------------------------------+---------------------+ 16 | | onERC721Received | [] | pure | 17 | | onERC1155Received | [] | pure | 18 | | onERC1155BatchReceived | [] | view | 19 | | supportsInterface | [] | view | 20 | | supportsInterface | [] | view | 21 | | getRoleMember | [] | view | 22 | | getRoleMemberCount | [] | view | 23 | | getRoleMembers | [] | view | 24 | | _grantRole | [] | | 25 | | _revokeRole | [] | | 26 | | supportsInterface | [] | view | 27 | | hasRole | [] | view | 28 | | _checkRole | [] | | 29 | | _checkRole | [] | | 30 | | getRoleAdmin | [] | view | 31 | | grantRole | ['getRoleAdmin', 'onlyRole'] | yes | 32 | | revokeRole | ['getRoleAdmin', 'onlyRole'] | yes | 33 | | renounceRole | [] | yes | 34 | | _setRoleAdmin | [] | | 35 | | _grantRole | [] | | 36 | | _revokeRole | [] | | 37 | | supportsInterface | [] | view | 38 | | hasRole | [] | view | 39 | | getRoleAdmin | [] | view | 40 | | grantRole | [] | yes | 41 | | revokeRole | [] | yes | 42 | | renounceRole | [] | yes | 43 | | _msgSender | [] | view | 44 | | _msgData | [] | view | 45 | | _contextSuffixLength | [] | view | 46 | | getRoleMember | [] | view | 47 | | getRoleMemberCount | [] | view | 48 | | pauseUsed | [] | view | 49 | | paused | [] | view | 50 | | pause | ['whenNotPaused'] | yes | 51 | | _updatePauseDuration | [] | | 52 | | _setPauseTime | [] | | 53 | | _grantGuardian | [] | | 54 | | constructor | [] | | 55 | | initialize | [] | yes | 56 | | getAllProposals | [] | view | 57 | | atIndex | [] | view | 58 | | positionOf | [] | view | 59 | | supportsInterface | [] | view | 60 | | isOperation | [] | view | 61 | | isOperationReady | [] | view | 62 | | isOperationDone | [] | view | 63 | | isOperationExpired | [] | view | 64 | | hashOperation | [] | view | 65 | | hashOperationBatch | [] | view | 66 | | getCalldataChecks | [] | view | 67 | | checkCalldata | [] | view | 68 | | schedule | ['onlySafe', 'whenNotPaused'] | yes | 69 | | scheduleBatch | ['onlySafe', 'whenNotPaused'] | yes | 70 | | execute | ['whenNotPaused'] | yes | 71 | | executeBatch | ['whenNotPaused'] | yes | 72 | | cancel | ['onlySafe', 'whenNotPaused'] | yes | 73 | | cleanup | ['whenNotPaused'] | yes | 74 | | pause | ['whenNotPaused'] | yes | 75 | | executeWhitelisted | ['onlyRole', 'whenNotPaused'] | yes | 76 | | executeWhitelistedBatch | ['onlyRole', 'whenNotPaused'] | yes | 77 | | grantRole | ['getRoleAdmin', 'onlyRole'] | yes | 78 | | revokeRole | ['getRoleAdmin', 'onlyRole'] | yes | 79 | | renounceRole | [] | yes | 80 | | revokeHotSigner | ['onlySafe'] | yes | 81 | | setGuardian | ['onlyTimelock'] | yes | 82 | | addCalldataChecks | ['onlyTimelock'] | yes | 83 | | addCalldataCheck | ['onlyTimelock'] | yes | 84 | | removeCalldataCheck | ['onlyTimelock'] | yes | 85 | | removeAllCalldataChecks | ['onlyTimelock'] | yes | 86 | | updateDelay | ['onlyTimelock'] | yes | 87 | | updateExpirationPeriod | ['onlyTimelock'] | yes | 88 | | updatePauseDuration | ['onlyTimelock'] | yes | 89 | | _schedule | [] | | 90 | | _afterCall | [] | | 91 | | _execute | [] | | 92 | | _addCalldataCheck | [] | | 93 | | _addCalldataChecks | [] | | 94 | | _removeCalldataCheck | [] | | 95 | | _removeAllCalldataChecks | [] | | 96 | | tokensReceived | [] | pure | 97 | | receive | [] | yes - logs | 98 | +-------------------------------------+-------------------------------+---------------------+ 99 | ``` 100 | 101 | Functions starting with an underscore are internal or private functions and cannot be called from outside the contract. 102 | -------------------------------------------------------------------------------- /docs/AUDIT_LOG.md: -------------------------------------------------------------------------------- 1 | # Audit Log 2 | 3 | **08/26/24** - While writing the Certora specifications for the pause functionality, it was discovered that the pause duration could be extended after a pause, thus re-pausing an already unpaused contract. This issue was remediated in commit [f3752cb5793f0b8ae83d02a74867967b9d87ca56](https://github.com/solidity-labs-io/safe-time-guard/pull/17/commits/f3752cb5793f0b8ae83d02a74867967b9d87ca56). 4 | 5 | **09/17/24** - Function `addCalldataCheck` can be removed from the contract to save bytecode space. 6 | `updateDelay` should not be able to cause live proposals to extend or delay their current execution time. 7 | `updateExpirationPeriod` can cause an executable proposal to become unexecutable, and conversely cause a non executable proposal to become executable. This is expected behavior. 8 | `updatePauseDuration` can only be called while the contract is not paused, therefore it cannot re-pause the contract. 9 | 10 | Timelock contract had its Access Controls checked to ensure only authorized users can call certain functions. A unit test was added to ensure that hot signers could not create new roles. Making the Timelock upgradeable was discussed at a surface level. This would necessitate adding a padding variable at the start of the contract for the implementation address. 11 | 12 | ------------------------------------------------------- 13 | | Function | Access | 14 | |-------------------------------|---------------------| 15 | | schedule | safe | 16 | | scheduleBatch | safe | 17 | | cancel | safe | 18 | | pause | pauser | 19 | | revokeHotSigner | safe | 20 | | executeWhitelisted | hot signer | 21 | | executeWhitelistedBatch | hot signer | 22 | | setGuardian | timelock | 23 | | addCalldataCheck | timelock | 24 | | addCalldataChecks | timelock | 25 | | removeCalldataCheck | timelock | 26 | | removeAllCalldataChecks | timelock | 27 | | updateDelay | timelock | 28 | | updateExpirationPeriod | timelock | 29 | | updatePauseDuration | timelock | 30 | | execute | open | 31 | | executeBatch | open | 32 | | cleanup | open | 33 | | grantRole | timelock(admin)| 34 | | revokeRole | timelock(admin)| 35 | | renounceRole | hot signer | 36 | 37 | Contracts `Guard`, `InstanceDeployer` had all of their lines of code reviewed. 38 | 39 | Timelock specification was reviewed and re-run with a minor [specification change](https://prover.certora.com/output/651303/d811372eab754157862d4db4937f6500?anonymousKey=24e38bec8ccd5467acec7d2b311a76b092227624) to ensure the `setLength` function in the formal specification was correct. The specification was found to be correct in its `setLength` function because the induction base case in the constructor with concrete values failed, and the remaining cases failed the vaccuity check, which means they always were false for any input. This confirms that this function worked as expected. 40 | 41 | **09/18/24** - RecoverySpell contract was found to have issues with the way it calculated the number of calls. If the recovery spell only had a single signer, it would try to add that signer two times, which would fail and cause the recovery to not work. It was also found that owners in the recovery spell could be the 0 address, so a check was added in the factory to prevent this. 42 | 43 | **09/22/24** - The RecoverySpell contract was found to have an issue with how it recovered signatures, making it vulnerable to signature malleability. This was fixed by using the OZ ecrecover library which checks for manipulated signatures. Events that were not being indexed were also fixed to be indexed to make it easier for the front end to search and filter events. 44 | 45 | **09/23/24** - Calldatacheck logic in Timelock contract was found to have issue where a duplicate AND check can be added for the same start and end indexes for a selector. It would make it impossible for a hot signer to take actions for the given protocol. Also, it was found that overlapping index ranges could be added which should not be possible. Also, the removal of a single OR check is not possible, first all the OR checks have to be removed and then re add the needed checks, which should not be the case. 46 | 47 | **09/24/24** - The Timelock contract was found to have an issue where an empty check could be added by passing an empty array of checks to the addCallDataCheck functions. This empty check would be impossible to fulfill on its own, meaning other checks would need to be added to that same index to make it possible to pass that given index check as a hot signer. Also, the Timelock factory contract had its mapping of deployed contracts removed to cut down on its bytecode size. 48 | 49 | **09/30/24** - The selfAddressCheck logic was removed from the timelock to simplify the contract. 50 | -------------------------------------------------------------------------------- /docs/AUDIT_SCOPE.md: -------------------------------------------------------------------------------- 1 | # Audit Scope 2 | 3 | The following contracts are in scope for the audit: 4 | 5 | - [x] [src/ConfigurablePause.sol](../src/ConfigurablePause.sol) 6 | - [x] [src/Timelock.sol](../src/Timelock.sol) 7 | - [x] [src/TimelockFactory.sol](../src/TimelockFactory.sol) 8 | - [x] [src/Guard.sol](../src/Guard.sol) 9 | - [x] [src/views/AddressCalculation.sol](../src/views/AddressCalculation.sol) 10 | - [x] [src/utils/Create2Helper.sol](../src/utils/Create2Helper.sol) 11 | - [x] [src/utils/Constants.sol](../src/utils/Constants.sol) 12 | - [x] [src/InstanceDeployer.sol](../src/InstanceDeployer.sol) 13 | - [x] [src/RecoverySpell.sol](../src/RecoverySpell.sol) 14 | - [x] [src/RecoverySpellFactory.sol](../src/RecoverySpellFactory.sol) 15 | - [x] [src/deploy/SystemDeploy.s.sol](../src/deploy/SystemDeploy.s.sol) 16 | - [x] [src/BytesHelper.sol](../src/BytesHelper.sol) Function `getFirstWord` is out of scope in this file. 17 | 18 | ## Lines of Code 19 | 20 | ``` 21 | cloc src/RecoverySpell.sol src/RecoverySpellFactory.sol src/ConfigurablePause.sol src/Timelock.sol src/TimelockFactory.sol src/Guard.sol src/views/AddressCalculation.sol src/utils/* src/BytesHelper.sol src/deploy/SystemDeploy.s.sol src/InstanceDeployer.sol 22 | ``` 23 | 24 | Output: 25 | ``` 26 | 12 text files. 27 | 12 unique files. 28 | 0 files ignored. 29 | 30 | github.com/AlDanial/cloc v 1.94 T=0.03 s (454.4 files/s, 109961.9 lines/s) 31 | ------------------------------------------------------------------------------- 32 | Language files blank comment code 33 | ------------------------------------------------------------------------------- 34 | Solidity 12 378 916 1610 35 | ------------------------------------------------------------------------------- 36 | SUM: 12 378 916 1610 37 | ------------------------------------------------------------------------------- 38 | ``` 39 | 40 | ## Out of Scope 41 | 42 | The following findings are out of scope for the audit: 43 | - any items or known issues in the documentation are out of scope 44 | - any items or edgecases described in the codebase itself are out of scope 45 | - any items found in the Recon audit are out of scope 46 | -------------------------------------------------------------------------------- /docs/CALLDATA_WHITELISTING.md: -------------------------------------------------------------------------------- 1 | # Calldata Whitelisting 2 | 3 | Kleidi supports whitelisting calldata for specific functions. Users can whitelist multilple values for any function. This is useful when you want to restrict the calldata to a specific set of values. 4 | 5 | The following are the ways to whitelist calldata for a function: 6 | 7 | 1. Direct calldata checking. This means that the calldata is checked for a specific value or set of values for the given indexes. 8 | 2. Calldata wildcard, meaning no checks are performed for that function. 9 | 10 | ## Morpho Example 11 | 12 | Take the Morpho Blue smart contracts as an example. The contract is monolithic and has one function to supply across all markets. 13 | 14 | The function signature is as follows: 15 | 16 | ```solidity 17 | struct MarketParams { 18 | address loanToken; 19 | address collateralToken; 20 | address oracle; 21 | address irm; 22 | uint256 lltv; 23 | } 24 | 25 | function supply( 26 | MarketParams memory marketParams, 27 | uint256 assets, 28 | uint256 shares, 29 | address onBehalf, 30 | bytes memory data 31 | ) external returns (uint256 assetsSupplied, uint256 sharesSupplied); 32 | ``` 33 | 34 | This means that to supply to any market, the same function is called with different calldata parameters. This is where the array of calldata checks is used. 35 | 36 | ```solidity 37 | struct Index { 38 | uint16 startIndex; 39 | uint16 endIndex; 40 | EnumerableSet.Bytes32Set dataHashes; 41 | } 42 | 43 | contract address => bytes4 function selector => Index[] calldataChecks; 44 | ``` 45 | 46 | There is a mapping that stores the smart contract address to the function selector to the array of calldata checks. This is used to check the calldata passed by the hot signers to call the allowed functions and ensure the calls comply with the rules. A function selector on a contract address can have multiple AND checks. AND check implies that all of these checks should pass. All of these AND checks will have non overlapping start index to end index range amongst each other. For a specific pair of start and end index there can be only one AND check, but the pair can have multiple hashes stored in `dataHashes` set. If the calldata matches with any of the data hashes the check would pass. This is referred as OR check. 47 | 48 | Overall, for whitelisting a function on a contract there can be multiple AND checks that can be added for different parts of the calldata and each AND check can have multiple OR checks. -------------------------------------------------------------------------------- /docs/DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Addresses 2 | 3 | This system version relies on Safe release [1.3.0](https://github.com/safe-global/safe-smart-account/blob/bf943f80fec5ac647159d26161446ac5d716a294/CHANGELOG.md#version-130-libs0). The following addresses are used in the system across all chains: 4 | 5 | ```MULTICALL3: 0xcA11bde05977b3631167028862bE2a173976CA11``` 6 | 7 | ```SAFE_LOGIC: 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552``` 8 | 9 | ```GNOSIS_SAFE_PROXY_FACTORY: 0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2``` 10 | 11 | All system contracts will be deployed using the Create2 Factory embedded within Foundry. The system addresses will be the same across all chains to ensure addresses for user contracts match up across all chains, allowing deterministic and counterfactual deployments. 12 | -------------------------------------------------------------------------------- /docs/EDGECASES.md: -------------------------------------------------------------------------------- 1 | # System Edge Cases 2 | 3 | ## Malicious Hot Signer 4 | 5 | If a user's hot signer key is compromised, funds are sent to the timelock address on another chain where the wallet has yet to be created, and a malicious user creates a wallet with the same address as the timelock, the malicious user can drain the timelock. They can call InstanceDeployer and create a new Instance, passing their own calldata, which will allow them to drain all funds from the timelock. 6 | 7 | # Wrapped vs Raw Ether 8 | 9 | The timelock does not have any checks on sending Raw Ether to whitelisted protocols. If an incorrect contract address is whitelisted and a corresponding function that accepts ETH in a malicious manner is also whitelisted, the timelock could potentially be drained of all its ETH. This can be mitigated by wrapping the ETH in the timelock into WETH. It is recommended to use WETH for all ETH transactions in the timelock and to whitelist the WETH deposit function. This enables all hot signers to wrap any ETH in the timelock to WETH, but not unwrap it. 10 | 11 | Value is not checked for hot signer calls, which means if a protocol allows excess value to be sent and not refunded, and that protocol is whitelisted, the timelock's eth can be drained. This can be mitigated by wrapping the ETH in the timelock into WETH and not allowing unwrapping by the hot signers. 12 | 13 | ## Recovery Spells and Front Running 14 | 15 | If an attacker finds a counterfactual timelock address on a chain where it has not been deployed yet. They can front-run and deploy a safe to that chain without using the Instance Deployer. However, they cannot call `createSystemInstance` on the Instance Deployer as they do not have the hot signer private key to deploy the timelock. This means that the recovery spells are safe from front-running attacks because once the safe is deployed by the attacker, a recovery spell can be created, but it cannot be used because it is not a module in the safe yet. It can only become a module in the safe once the system is deployed from the InstanceDeployer, at which point the recovery spell becomes a module in the safe. 16 | 17 | Recovery spells cannot be deployed unless a safe is deployed first. 18 | 19 | Order of operations for deployment (unexpected): 20 | 21 | 1. Deploy safe 22 | 2. Deploy recovery spell 23 | 3. Deploy system instance 24 | 25 | Alternative order of operations for deployment (expected): 26 | 27 | 1. Deploy system instance 28 | 2. Deploy recovery spell 29 | 30 | # Malicious Collusion 31 | 32 | If both the guardian and recovery signers are malicious, they could collude to take over the system. The guardian could pause the system and the recovery signers could execute a recovery spell to drain the timelock, which the cold signers could not counter. This can be mitigated by having a trusted guardian and recovery signers and neither having knowledge of the other, so in case one defects, funds are still safe. Alternatively, a user could opt to only have a guardian or recovery signers, but not both. 33 | 34 | # Proposal Lifecycle 35 | 36 | This section will explore the lifecycle of the proposal from scheduling to execution and describe all of the system states along the way. 37 | 38 | ## Assumptions 39 | 40 | All system contracts are deployed across chains using the Arachnid Create2 deployer contract. This means the timelock factory, guard, multicall3, and safe addresses will be the same across all chains. If this is not true, then the system will not work as expected. 41 | 42 | 43 | ## Deployer Parameters 44 | - hot signers must be the same across all chains and the hot signer in the deployment list must not be compromised. A compromised hot signer can take over and drain a new timelock it deploys. 45 | 46 | ## Timelock Parameters 47 | - The Timelock Factory uses the message sender to calculate the address of the timelock. This means the timelock address will be the same across all chains as the TimelockFactory is called by the InstanceDeployer. 48 | 49 | ## Deployment Parameters 50 | - The following parameters can affect the deployment address of the system: 51 | - gnosis safe owners 52 | - gnosis safe quorum 53 | - timelock delay 54 | - timelock expiration period 55 | - pause guardian 56 | - pause duration 57 | - hot signers 58 | - salt 59 | If the aforemented parameters are changed, the address of the deployed contracts will change. 60 | 61 | - The following parameters do not affect the deployment address of the system: 62 | - recovery spells (this would create a circular dependency if it was used to calculate the address, meaning no recovery spells could be provided during construction) 63 | - whitelisted targets, selectors and calldatas 64 | 65 | This means if the aforementioned parameters are changed, the changes should not affect the address of the deployed contracts. 66 | 67 | ## Future Standards 68 | 69 | This wallet does not support future token standards that may be developed. This system is intentionally immutable and new protocols and token standards can only be supported by creating a new system instance with the updated contracts. 70 | 71 | ### Malicious Recovery Spell Scenario 72 | 73 | If the time delay on a recovery spell is shorter than the timelock, the recovery spell can kick the safe owners without the current safe owners being able to veto this change. This is why it is important to have a longer time delay on the recovery spells than on the timelock. 74 | 75 | ### Malicious Safe Signer Takeover Scenario 76 | 77 | Conversely, if the Safe keys are compromised, and the recovery spell time delay is longer than the timelock, then the attacker can rotate the keys on the Safe through a timelocked transaction and remove the recovery spell as a module. However, if the guardian is set, and the pause duration is longer than the recovery spell period, the guardian can pause the timelock, cancelling all malicious proposals, which stops them from being executed, and then the recovery spell can execute and rotate signing keys. 78 | 79 | ## Guardian 80 | 81 | Given the following scenario: 82 | 83 | 1. Cold signers are compromised 84 | 2. Guardian and social recovery module are set 85 | 3. Recovery spell delay is shorter than guardian pause duration 86 | 87 | The guardian can pause the timelock, cancelling all malicious proposals, which stops them from being executed, and then the recovery spell can execute and rotate signing keys. 88 | 89 | ### Malicious Recovery Spell Scenario with Guardian 90 | 91 | If the guardian is set, and a recovery spell is malicious, the guardian cannot veto this change. This is why it is important to have a longer time delay on the recovery spells than the timelock. 92 | 93 | ### Malicious Safe Signer Takeover Scenario with Guardian 94 | 95 | If the Safe keys are compromised, and the guardian is set, and the guardian can be reached before a malicious proposal becomes executable, and a recovery spell exists whose time delay is shorter than the guardian pause duration, the guardian can pause the timelock, cancelling all malicious proposals, which stops them from being executed, and then the recovery spell can execute and rotate signing keys. 96 | 97 | ## Timelock 98 | 99 | The timelock calldata whitelisting feature can be abused and set to malicious targets, or targets such as DEX's that allow swapping of tokens. If malicious targets and calldatas are whitelisted, the timelock can have all of its funds drained immediately. 100 | 101 | It is recommended to have the timelock delay be shorter than the recovery spell delay so that the timelock can cancel malicious recovery spells. 102 | 103 | ## Onchain Policy Engine 104 | 105 | The timelock calldata whitelisting feature acts as an onchain policy engine, enforcing that transactions the hot signers send are guaranteed to match the whitelisted calldata. This is a powerful feature that can be used to enforce that the hot signers can never lose funds or sign messages that can drain the wallet. This speeds the process of interacting with DeFi protocols for power users who want the ability to quickly interact with DeFi protocols without the cognitive overhead of checking calldata byte-by-byte. 106 | -------------------------------------------------------------------------------- /docs/INVARIANTS.md: -------------------------------------------------------------------------------- 1 | # System Invariants 2 | 3 | ## Timelock Variables 4 | 5 | ## timestamps and _liveProposals 6 | 7 | The two variables timestamps and _liveProposals are closely related. When a proposal has been proposed, the proposal timestamp is set to the current block timestamp plus the delay. The proposal id is then added to the _liveProposals enumerable set. 8 | 9 | ### Scheduled 10 | - **timestamps[id]**: block.timestamp + delay 11 | - **_liveProposals**: contains id 12 | 13 | ### Executed 14 | - **timestamps[id]**: 1 15 | - **_liveProposals**: does not contain id 16 | 17 | ### Canceled 18 | - **timestamps[id]**: 0 19 | - **_liveProposals**: does not contain id 20 | 21 | ### Expired & Not Cleaned Up 22 | - **timestamps[id]**: block.timestamp + delay 23 | - **_liveProposals**: does contain id 24 | 25 | ### Expired & Cleaned Up 26 | - **timestamps[id]**: block.timestamp + delay 27 | - **_liveProposals**: does not contain id 28 | 29 | ## Timelock Parameters 30 | 31 | The timelock can never have whitelisted calldata to the safe or the timelock itself. This is to prevent the hot signers from being able to execute arbitrary transactions on the safe or itself. 32 | 33 | ## Timelock Calldata 34 | 35 | All calldata checks must be isolated to predefined calldata segments, for example given calldata with three parameters: 36 | 37 | 0xffffeecc000000000000000112818929111111000000000000000112818929111111000000000000000112818929111111 38 | 39 | 0xffffeecc 40 | 41 | 42 | ``` 43 | 1. 2. 3. 44 | 000000000000000112818929111111 45 | 000000000000000112818929111111 46 | 000000000000000112818929111111 47 | 48 | A B C 49 | D E F 50 | a || d && b || e && c || f 51 | ``` 52 | 53 | example hot signer call to function 54 | 55 | parameter 1 was A, Parameter 2 was E, parameter 3 was G 56 | 57 | checks must be applied in a way such that they do not overlap with each other. It is important that the calldata checks are isolated to specific segments of the calldata to ensure no duplicate checks are allowed. 58 | 59 | A given function on a contract can have multiple calldata checks for different indexes, but they must not share the same start or end indexes or overlap. 60 | 61 | ## Recovery Spells 62 | 63 | Recovery spells are modules that are authorized to make changes to the Safe contract without the Timelock. These are used to recover the Safe contract in the event that the safe signers permanently go offline. 64 | 65 | The following must always be true for any recovery spell created through the factory: 66 | 67 | - The recovery threshold must be greater than or equal to 0 and less than or equal to the number of new owners. 68 | - The threshold must be greater than or equal to 1 and less than or equal to the number of new owners. 69 | - The new owners are all non zero addresses. 70 | - There are no duplicate new owners. 71 | - There is at least one new owner. 72 | - The recovery delay is less than or equal to one year. 73 | - The recovery spell can not be created if the safe contract has not been deployed. -------------------------------------------------------------------------------- /docs/KNOWN_ISSUES.md: -------------------------------------------------------------------------------- 1 | # Known Issues 2 | 3 | The following are known issues with the Kleidi system: 4 | - if the cold signers are malicious or compromised, they can execute transactions to compromise the system if neither recovery spells or the guardian are used 5 | - if the hot signers are malicious or compromised, they can deploy a compromised system instance on a new chain with compromised recovery spells and malicious calldata checks that allow funds to be stolen 6 | - if DEX's are whitelisted, this opens up the ability for the hot signers to steal funds from the system via high slippage and front and back running. DEX's are not whitelisted in the system by default and will not be displayed on the frontend until significant future code is developed to safely enable this use case. For now, this is a known issue and won't fix. 7 | - if a malicious protocol is whitelisted, this opens up the ability for hot signers to inadvernantly lose funds 8 | - if a non-malicious but improperly configured protocol is whitelisted, this opens up the ability for hot signers to inadvernantly lose funds by using a protocol incorrectly 9 | - fee on transfer tokens may make the actual amount of tokens sent to destinations less or more than expected, this finding is out of scope for the system 10 | - the system only works on EVM compatible chains, does not work on chains that have not undergone the Shanghai EVM upgrade 11 | - the system only works with contracts that have a known ABI, it does not work with contracts that have dynamic ABIs 12 | - the return value of token transfers are unchecked, however the call to the token contract is checked, the timelock has no accounting mechanisms 13 | - salt in the DeploymentParams struct is not used in the call to createSystemInstance, this is a known issue, but is not a security concern. The same system instance with the same parameters can only be deployed once. 14 | - the system does not enforce that hot, cold and recovery signers are separate, this is a known issue, however this should never happen in practice as the frontend contains business logic to prevent this from happening. 15 | - Dynamic calldata such as arrays cannot be checked with this method in the timelock as parameters can be at unpredictable indexes depending on the array length. However, parameters can be safely constructed using external modules. However, DeFi protocols that are planned to be supported on launch all have fixed size calldata, so this is not a concern. 16 | -------------------------------------------------------------------------------- /docs/MORPHO_EXAMPLE.md: -------------------------------------------------------------------------------- 1 | 2 | user wants to whitelist three USDC morpho markets 3 | 4 | morpo has a single contract where all operations flow through a single function for deposit, and they flow through a single function on withdraw 5 | 6 | the function signature is as follows: 7 | 8 | ```solidity 9 | struct MarketParams { 10 | address loanToken; 11 | address collateralToken; 12 | address oracle; 13 | address irm; 14 | uint256 lltv; 15 | } 16 | 17 | function supply( 18 | MarketParams memory marketParams, 19 | uint256 assets, 20 | uint256 shares, 21 | address onBehalf, 22 | bytes memory data 23 | ) external returns (uint256 assetsSupplied, uint256 sharesSupplied); 24 | 25 | selector 0x928182123 26 | allowed market params: [1, 2] 27 | calldatachecks : [[startIndex: 4, endIndex: 164], [startIndex: 228, endIndex: 260]] 28 | allowed onBehalf: [timelock] 29 | ``` 30 | 31 | the withdraw function is similar: 32 | 33 | ```solidity 34 | function withdraw( 35 | MarketParams memory marketParams, 36 | uint256 assets, 37 | uint256 shares, 38 | address to, 39 | bytes memory data 40 | ) external returns (uint256 assetsWithdrawn, uint256 sharesWithdrawn); 41 | 42 | selector 0x872387fe 43 | calldatachecks: [[startIndex: 228, endIndex: 260]] 44 | allowed to: [timelock] 45 | ``` 46 | 47 | 1. call approve on usdc to approve morpo to spend the usdc 48 | 2. call supply on morpho to deposit usdc 49 | 3. call withdraw on morpho to withdraw usdc back to the timelock 50 | 51 | calldata to whitelist for market 1: 52 | 53 | 1. selector 0x095ea7b3 on USDC with parameter 1 as the timelock address, parameter 2 is the value and is unchecked. start index is 16 and end index is 36 because an address is 20 bytes and the first 12 bytes are garbage data that are unused 54 | 55 | 2. selector 0x928182123 on morpho with parameter 1 as the market params, parameter 4 as the timelock address, the remaining parameters are unchecked 56 | 57 | 3. selector 0x872387fe on morpho with parameter 4 as the timelock address, this makes all withdrawals be sent to the timelock, the remaining parameters are unchecked 58 | 59 | calldata to whitelist for market 2: 60 | 61 | 1. selector 0x928182123 on morpho with parameter 1 as the 2nd market params 62 | 63 | calldata to whitelist for market 3: 64 | 65 | 1. selector 0x928182123 on morpho with parameter 1 as the 3rd market params 66 | -------------------------------------------------------------------------------- /docs/REQUIREMENTS.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | The user must never remove the timelock from the safe as a module. If they do, the user will never be able to rotate their signers, change quorum, or upgrade their safe logic contract, except with a recovery spell or other module that is authorized. 4 | 5 | The only way to safely remove the timelock as a module on the safe is to first remove the guard, then remove the timelock as a module. This way, the user can still rotate their signers, change quorum, and upgrade their safe logic contract. 6 | 7 | ## Deployment 8 | 9 | All deployments must be done through the [InstanceDeployer.sol](src/InstanceDeployer.sol) contract. If a user attempts to deploy the contracts directly, it should be assumed that the system is unsafe due to the timelock being able to be initialized without checks. Additionally, unsafe modules could be added to the Timelock. 10 | 11 | ## Guard 12 | 13 | The Guard contract prevents the Safe contract from directly modifying its own parameters. Instead, it requires the Timelock to make changes to the Safe contract. This is to prevent the Safe contract from instantly rotating signers, changing quorum or upgrading its implementation contract. 14 | 15 | ## Recovery Spells 16 | 17 | Recovery spells are modules that are authorized to make changes to the Safe contract without the Timelock. These are used to recover the Safe contract in the event that the safe signers permanently go offline. 18 | 19 | 20 | ## Gnosis Safe 21 | 22 | The Gnosis Safe should only be created through the [InstanceDeployer.sol](src/InstanceDeployer.sol) contract. If it is created through another means, it will not be able to be properly initialized automatically using the InstanceDeployer. 23 | -------------------------------------------------------------------------------- /docs/TESTING.md: -------------------------------------------------------------------------------- 1 | # Test List 2 | 3 | - cross chain deployment of the wallet deployer system, check that all addresses are the same on each chain 4 | - cross chain deployment, same contract addresses, different whitelisted calldata and addresses 5 | 6 | ## Scenario Test 7 | 8 | - [ ] full Morpho Blue whitelisted calldata walkthrough 9 | - [x] supply collateral 10 | - [x] withdraw collateral 11 | - [x] borrow 12 | - [x] repay 13 | - [x] supply borrow asset 14 | - [x] withdraw borrow asset 15 | - [x] deploy safe with timelock, guard, and recovery spell, safe owners enact malicious proposal, guardian pauses, recovery spell rotates signers, new transactions are sent 16 | - [x] hot signers move funds from one DeFi protocol to another 17 | - [x] hot signers get revoked by the Safe 18 | - [ ] privilege escalation impossible by hot signers 19 | - [x] mutative functions cannot be called while paused 20 | 21 | # Formal Verification 22 | 23 | - [x] impossible to have self calls whitelisted 24 | - [x] pausing cancels all in flight proposals 25 | - [x] not possible to have more than one admin 26 | - [x] not possible to create new roles => implies no other roles outside of admin and hot signers can have any addresses in their list 27 | - [x] timelock duration is always less than or equal to the maximum timelock duration, and always greater than or equal to the minimum timelock duration 28 | 29 | ## Proposal Invariants 30 | 31 | - !isOperationExpired(id) => **timestamps[id]** > 1 => **_liveProposals** does contain id 32 | - **timestamps[id]** == 1 => **_liveProposals** does not contain id 33 | 34 | 35 | # Mutation Testing 36 | 37 | Run unit, integration tests and formal verification spec against Timelock mutants generated by certora gambit 38 | 39 | generate mutants: 40 | ```bash 41 | gambit mutate --json certora/mutation/TimelockConfig.json 42 | ``` 43 | 44 | run tests against mutants: 45 | ```bash 46 | sh run-timelock-mutation.sh 47 | ``` 48 | 49 | `run-timelock-mutation` will generate a readme file with testing results against each mutation. It shows failing unit tests, failing integration tests, whether the formal verification spec was violated, and provides a url to the certora prover run for each mutant. A total of 289 mutants are generated for the `Timelock` contract. It skips some of the mutants to avoid infinite looping, as in those mutants increment operator for a loop is replaced by assert(true). It also skips a few invariants and rules from the timelock spec which failed sanity checks on the original timelock contract. This is done so that when there is a further sanity check failure by a mutant the spec can be marked as violated. 50 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | 2 | [profile.default] 3 | src = "src" 4 | out = "out" 5 | libs = ["lib"] 6 | optimizer_runs = 300 7 | solc_version = "0.8.25" 8 | evm_version="Cancun" 9 | fs_permissions = [{ access = "read", path = "./"}] 10 | gas_limit = "18446744073709551615" 11 | 12 | [fuzz] 13 | runs = 256 14 | 15 | [fmt] 16 | line_length = 80 17 | 18 | # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options 19 | 20 | [rpc_endpoints] 21 | ethereum = "${ETH_RPC_URL}" 22 | optimism = "${OP_RPC_URL}" 23 | base = "https://mainnet.base.org" 24 | baseSepolia = "https://sepolia.base.org" 25 | optimismSepolia = "https://sepolia.optimism.io" 26 | localhost = "http://127.0.0.1:8545" 27 | 28 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @openzeppelin-contracts/=lib/openzeppelin-contracts/ 2 | @safe/=lib/safe-smart-account/contracts/ 3 | @src/=src/ 4 | @forge-std/=lib/forge-std/src/ 5 | @interface/=src/interface/ 6 | @forge-proposal-simulator=lib/forge-proposal-simulator/ 7 | -------------------------------------------------------------------------------- /run-timelock-mutation.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source .env 3 | 4 | output_title() { 5 | local text="# $1" 6 | echo "$text" > MutationTestOutput/TimelockResult.md 7 | } 8 | 9 | output_heading() { 10 | local text="\n## $1" 11 | echo "$text" >> MutationTestOutput/TimelockResult.md 12 | } 13 | 14 | output_results() { 15 | local result="$1" 16 | local heading="$2" 17 | local content="
18 | $heading\n 19 | \`\`\`\n$result\n\`\`\` 20 |
" 21 | echo "$content" >> MutationTestOutput/TimelockResult.md 22 | } 23 | 24 | # Function to extract the last line from a file 25 | get_last_line() { 26 | local file="$1" 27 | tail -n 1 "$file" 28 | } 29 | 30 | get_content_after_pattern() { 31 | local output="$1" 32 | local pattern="$2" 33 | 34 | line_number=$(grep -n "$pattern" "$output" | head -n 1 | cut -d ':' -f1) 35 | 36 | content_after_n_lines=$(tail -n +$((line_number)) "$output") 37 | 38 | clean_content=$(echo "$content_after_n_lines" | sed 's/\x1B\[[0-9;]*m//g') 39 | 40 | # Return the extracted content 41 | echo "$clean_content" 42 | } 43 | 44 | get_first_line_with_pattern() { 45 | local file="$1" 46 | local pattern="$2" 47 | 48 | local line=$(grep "$pattern" "$file" | head -n 1) 49 | 50 | echo "$line" 51 | } 52 | 53 | # Function to check if an index is in the skip array 54 | should_skip() { 55 | local index="$1" 56 | for skip in "${skip_indexes[@]}"; do 57 | if [[ "$skip" == "$index" ]]; then 58 | return 0 # Return true if index should be skipped 59 | fi 60 | done 61 | return 1 # Return false if index should not be skipped 62 | } 63 | 64 | 65 | process_test_output() { 66 | local test_type="$1" 67 | local output="$2" 68 | 69 | echo "\n### $test_type:" >> MutationTestOutput/TimelockResult.md 70 | 71 | # Check if output contains "Failing tests: " 72 | if grep -q "Failing tests:" "$output"; then 73 | 74 | # Extract last line 75 | last_line=$(get_last_line "$output") 76 | 77 | # Remove escape sequences and color codes using sed 78 | clean_line=$(echo "$last_line" | sed 's/\x1B\[[0-9;]*m//g') 79 | 80 | # Extract failed and passed tests using awk 81 | failed_tests=$(echo "$clean_line" | awk '{print $5}') 82 | passed_tests=$(echo "$clean_line" | awk '{print $8}') 83 | 84 | # Mark current mutation as failed 85 | is_current_mutation_failed=1 86 | 87 | # Append to last_lines.txt with desired format 88 | echo "Failed $test_type: $failed_tests, Passed Tests: $passed_tests" >> MutationTestOutput/TimelockResult.md 89 | 90 | content_after_pattern=$(get_content_after_pattern "$output" "Failing tests:") 91 | output_results "$content_after_pattern" "View Failing tests" 92 | else 93 | # Extract last line 94 | last_line=$(get_last_line "$output") 95 | 96 | # Remove escape sequences and color codes using sed 97 | clean_line=$(echo "$last_line" | sed 's/\x1B\[[0-9;]*m//g') 98 | 99 | # Extract failed and passed tests using awk 100 | passed_tests=$(echo "$clean_line" | awk '{print $10}') 101 | 102 | # Append to last_lines.txt with desired format 103 | echo "Failed $test_type: 0, Passed Tests: $passed_tests" >> MutationTestOutput/TimelockResult.md 104 | fi 105 | } 106 | 107 | target_file="src/Timelock.sol" 108 | target_dir="MutationTestOutput" 109 | num_files=289 110 | 111 | # Create directory for output files if it doesn't exist 112 | mkdir -p "$target_dir" 113 | 114 | # Number of failed mutations 115 | failed_mutation=0 116 | 117 | is_current_mutation_failed=0 # Intialized as false 118 | 119 | # Array of mutation indexes to skip 120 | # Skip these mutations to avoid infinite for and while loop 121 | skip_indexes=(21 56 64 77 97 117 126 146 239 242 249 269 284 287) 122 | # skip_indexes=(1 2) 123 | 124 | # Append Mutation Result to TimelockResult.md with desired format 125 | output_title "Mutation Results\n" 126 | 127 | # Loop through the number of files 128 | for (( i=1; i <= num_files; i++ )); do 129 | # Check if the current index should be skipped 130 | if should_skip "$i"; then 131 | echo "Skipping mutation $i as it's in the skip list." 132 | continue 133 | fi 134 | 135 | # Construct dynamic file path using iterator 136 | file_path="gambit_out_Timelock/mutants/$i/src/Timelock.sol" 137 | 138 | # Check if file exists before copying 139 | if [[ -f "$file_path" ]]; then 140 | # Mark current mutation as not failed at the start of run 141 | (( is_current_mutation_failed=0 )) 142 | # Copy the file's contents to the target file 143 | cat "$file_path" > "$target_file" 144 | 145 | echo "Mutation $i" 146 | output_heading "Mutation $i" 147 | 148 | mutation_diff=$(gambit summary --mids $i --mutation-directory gambit_out_Timelock) 149 | clean_mutation_diff=$(echo "$mutation_diff" | sed 's/\x1B\[[0-9;]*m//g') 150 | output_results "$clean_mutation_diff" "View mutation diff" 151 | 152 | temp_output_file="$target_dir/temp.txt" 153 | 154 | touch "$temp_output_file" 155 | 156 | # Run unit tests and capture output 157 | echo "Runnin unit tests" 158 | unit_command_output=$(forge test --mc UnitTest -v --nmt testAddAndRemoveCalldataFuzzy) 159 | echo "$unit_command_output" > "$temp_output_file" 160 | # Process unit test outputs using the function 161 | process_test_output "Unit Tests" "$temp_output_file" 162 | 163 | # Run integration tests and capture output 164 | echo "Runnin integration tests" 165 | integration_command_output=$(forge test --mc IntegrationTest -v --fork-url $ETH_RPC_URL) 166 | echo "$integration_command_output" > "$temp_output_file" 167 | # Process integration test outputs using the function 168 | process_test_output "Integration Tests" "$temp_output_file" 169 | 170 | # Run certora prover and capture output 171 | echo "Running certora prover" 172 | output_heading "Certora Prover Results: \n" 173 | # exclude sanity check failures while mutation testing 174 | certora_run_output=$(certoraRun certora/confs/Timelock.conf --wait_for_results --exclude_rule setLengthInvariant operationExpired operationInSet removeAllCalldataChecks timestampInvariant) 175 | echo "$certora_run_output" > "$temp_output_file" 176 | echo "Certora prover run ended" 177 | 178 | # Extract the Certora Prover URL from the output 179 | certora_url_line=$(get_first_line_with_pattern "$temp_output_file" "https://prover.certora.com/output") 180 | if [[ -n "$certora_url_line" ]]; then 181 | certora_url=$(echo "$certora_url_line" | awk '{print $9}') 182 | echo "Certora Prover Output URL: $certora_url" >> MutationTestOutput/TimelockResult.md 183 | fi 184 | 185 | # Extract last line with certora prover result 186 | certora_result=$(get_last_line "$temp_output_file") 187 | # Remove escape sequences and color codes using sed 188 | clean_certora_result=$(echo "$certora_result" | sed 's/\x1B\[[0-9;]*m//g') 189 | echo "Certora Prover Result: $clean_certora_result" >> MutationTestOutput/TimelockResult.md 190 | 191 | if [[ "$clean_certora_result" == "Violations were found" ]]; then 192 | is_current_mutation_failed=1 193 | fi 194 | 195 | rm "$temp_output_file" 196 | 197 | if [[ $is_current_mutation_failed -eq 1 ]]; then 198 | # Increament total mutation failed 199 | (( failed_mutation++ )) 200 | fi 201 | 202 | else 203 | echo "Warning: File '$file_path' not found." 204 | fi 205 | done 206 | 207 | output_heading "Mutation Testing Result" 208 | skip_count=${#skip_indexes[@]} 209 | processed_num_files=$((num_files - skip_count)) 210 | echo "$failed_mutation failed out of $processed_num_files processed mutations(skipped $skip_count mutations)" >> MutationTestOutput/TimelockResult.md 211 | -------------------------------------------------------------------------------- /src/BytesHelper.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | library BytesHelper { 4 | /// @notice function to grab the first 4 bytes of calldata payload 5 | /// returns the function selector 6 | /// @param toSlice the calldata payload 7 | function getFunctionSignature(bytes memory toSlice) 8 | public 9 | pure 10 | returns (bytes4 functionSignature) 11 | { 12 | require(toSlice.length >= 4, "No function signature"); 13 | functionSignature = bytes4(toSlice); 14 | } 15 | 16 | /// @notice function to grab the first 32 bytes of returned memory 17 | /// @param toSlice the calldata payload 18 | function getFirstWord(bytes memory toSlice) 19 | public 20 | pure 21 | returns (uint256 value) 22 | { 23 | require(toSlice.length >= 32, "Length less than 32 bytes"); 24 | 25 | assembly ("memory-safe") { 26 | value := mload(add(toSlice, 0x20)) 27 | } 28 | } 29 | 30 | /// @notice function to grab a slice of bytes out of a byte string 31 | /// returns the slice 32 | /// @param toSlice the byte string to slice 33 | /// @param start the start index of the slice 34 | /// @param end the end index of the slice 35 | function sliceBytes(bytes memory toSlice, uint256 start, uint256 end) 36 | public 37 | pure 38 | returns (bytes memory) 39 | { 40 | require( 41 | start < toSlice.length, 42 | "Start index is greater than the length of the byte string" 43 | ); 44 | require( 45 | end <= toSlice.length, 46 | "End index is greater than the length of the byte string" 47 | ); 48 | require(start < end, "Start index not less than end index"); 49 | 50 | uint256 length = end - start + 1; 51 | bytes memory sliced = new bytes(length); 52 | 53 | /// 16 54 | for (uint256 i = 0; i < length; i++) { 55 | sliced[i] = toSlice[i + start]; 56 | } 57 | 58 | return sliced; 59 | } 60 | 61 | /// @notice function to get the hash of a slice of bytes 62 | function getSlicedBytesHash( 63 | bytes memory toSlice, 64 | uint256 start, 65 | uint256 end 66 | ) public pure returns (bytes32) { 67 | return keccak256(sliceBytes(toSlice, start, end)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/ConfigurablePause.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | /// naming: rename to pausable 4 | 5 | /// @notice possible states for this contract to be in: 6 | /// 1. paused, pauseStartTime != 0, guardian == address(0) 7 | /// 2. unpaused, pauseStartTime == 0, guardian != address(0) 8 | /// 3. unpaused, pauseStartTime <= block.timestamp - pauseDuration, guardian == address(0) 9 | contract ConfigurablePause { 10 | /// --------------------------------------------------------- 11 | /// --------------------------------------------------------- 12 | /// ------------------- STORAGE VARIABLES ------------------- 13 | /// --------------------------------------------------------- 14 | /// --------------------------------------------------------- 15 | 16 | /// @notice pause start time, starts at 0 so contract is unpaused 17 | uint128 public pauseStartTime; 18 | 19 | /// @notice pause duration 20 | uint128 public pauseDuration; 21 | 22 | /// @notice address of the pause guardian 23 | address public pauseGuardian; 24 | 25 | /// --------------------------------------------------------- 26 | /// --------------------------------------------------------- 27 | /// ------------------ CONSTANT VARIABLES ------------------- 28 | /// --------------------------------------------------------- 29 | /// --------------------------------------------------------- 30 | 31 | /// @notice minimum pause duration 32 | uint256 public constant MIN_PAUSE_DURATION = 1 days; 33 | 34 | /// @notice maximum pause duration 35 | uint256 public constant MAX_PAUSE_DURATION = 30 days; 36 | 37 | /// @notice emitted when the pause guardian is updated 38 | /// @param oldPauseGuardian old pause guardian 39 | /// @param newPauseGuardian new pause guardian 40 | event PauseGuardianUpdated( 41 | address indexed oldPauseGuardian, address indexed newPauseGuardian 42 | ); 43 | 44 | /// @notice event emitted when pause start time is updated 45 | /// @param newPauseStartTime new pause start time 46 | event PauseTimeUpdated(uint256 indexed newPauseStartTime); 47 | 48 | /// @notice event emitted when pause duration is updated 49 | /// @param oldPauseDuration old pause duration 50 | /// @param newPauseDuration new pause duration 51 | event PauseDurationUpdated( 52 | uint256 indexed oldPauseDuration, uint256 newPauseDuration 53 | ); 54 | 55 | /// @dev Emitted when the pause is triggered by `account`. 56 | event Paused(address indexed account); 57 | 58 | /// @dev Modifier to make a function callable only when the contract is not paused. 59 | modifier whenNotPaused() { 60 | require(!paused(), "Pausable: paused"); 61 | _; 62 | } 63 | 64 | /// ------------- VIEW ONLY FUNCTIONS ------------- 65 | 66 | /// @notice return the current pause status 67 | /// if pauseStartTime is 0, contract is not paused 68 | /// if pauseStartTime is not 0, contract could be paused in the pauseDuration window 69 | function paused() public view returns (bool) { 70 | return block.timestamp <= pauseStartTime + pauseDuration; 71 | } 72 | 73 | /// ------------- PAUSE FUNCTION ------------- 74 | 75 | /// @notice pause the contracts, can only pause while the contracts are unpaused 76 | /// uses up the pause, and starts the pause timer 77 | /// calling removes the pause guardian 78 | function pause() public virtual whenNotPaused { 79 | /// if msg.sender == pause guardian, contract is not paused 80 | /// this implies that pause is not used 81 | require( 82 | msg.sender == pauseGuardian, 83 | "ConfigurablePauseGuardian: only pause guardian" 84 | ); 85 | 86 | /// pause, set pauseStartTime to current block timestamp 87 | /// safe unchecked downcast because maximum would be 2^128 - 1 which is 88 | /// a very large number and very far in the future 89 | _setPauseTime(uint128(block.timestamp)); 90 | 91 | address previousPauseGuardian = pauseGuardian; 92 | /// kick the pause guardian 93 | pauseGuardian = address(0); 94 | 95 | emit PauseGuardianUpdated(previousPauseGuardian, address(0)); 96 | emit Paused(msg.sender); 97 | } 98 | 99 | /// ------------- INTERNAL/PRIVATE HELPERS ------------- 100 | 101 | /// @notice helper function to update the pause duration 102 | /// should only be called when the contract is unpaused 103 | /// @param newPauseDuration new pause duration 104 | function _updatePauseDuration(uint128 newPauseDuration) internal { 105 | require( 106 | newPauseDuration >= MIN_PAUSE_DURATION 107 | && newPauseDuration <= MAX_PAUSE_DURATION, 108 | "ConfigurablePause: pause duration out of bounds" 109 | ); 110 | 111 | /// if the contract was already paused, reset the pauseStartTime to 0 112 | /// so that this function cannot pause the contract again 113 | _setPauseTime(0); 114 | 115 | uint256 oldPauseDuration = pauseDuration; 116 | pauseDuration = newPauseDuration; 117 | 118 | emit PauseDurationUpdated(oldPauseDuration, pauseDuration); 119 | } 120 | 121 | /// @notice helper function to update the pause start time. used to pause the contract 122 | /// @param newPauseStartTime new pause start time 123 | function _setPauseTime(uint128 newPauseStartTime) internal { 124 | pauseStartTime = newPauseStartTime; 125 | 126 | emit PauseTimeUpdated(newPauseStartTime); 127 | } 128 | 129 | /// @dev when a new guardian is granted, the contract is automatically unpaused 130 | /// @notice grant pause guardian role to a new address 131 | /// this should be done after the previous pause guardian has been kicked, 132 | /// however there are no checks on this as only the owner will call this function 133 | /// and the owner is assumed to be non-malicious 134 | function _grantGuardian(address newPauseGuardian) internal { 135 | address previousPauseGuardian = pauseGuardian; 136 | pauseGuardian = newPauseGuardian; 137 | 138 | emit PauseGuardianUpdated(previousPauseGuardian, newPauseGuardian); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Guard.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {BaseGuard} from "@safe/base/GuardManager.sol"; 4 | import {Enum} from "@safe/common/Enum.sol"; 5 | import {Safe} from "@safe/Safe.sol"; 6 | 7 | import {BytesHelper} from "src/BytesHelper.sol"; 8 | 9 | /// @notice This guard restricts changing owners and modules. It enforces 10 | /// that the owners and modules remain the same after a safe transaction is 11 | /// executed by not allowing self or delegate calls. 12 | 13 | /// Config: 14 | /// - the timelock must be a module of the safe to enact changes to the owners and modules 15 | /// - the safe must be the only executor on the timelock 16 | 17 | /// no new modules, upgrades, owners, or fallback handlers can be added or 18 | /// removed by a transaction because all self calls are disallowed. 19 | /// this implies that the only way these values can be set are through 20 | /// the timelock, which can call back into the safe and use delegatecall 21 | /// if needed. 22 | 23 | /// Blocks all delegate calls, as the owners and modules could be changed. 24 | /// Does not allow changing of the implementation contract either through 25 | /// a normal safe transaction. 26 | /// The implementation contract can still be upgraded through the timelock 27 | /// using module calls back into the safe with a delegatecall. 28 | 29 | /// Refund receiver and gas params are not checked because the Safe itself 30 | /// does not hold funds or tokens. 31 | 32 | contract Guard is BaseGuard { 33 | using BytesHelper for bytes; 34 | 35 | /// ----------------------------------------------------- 36 | /// ----------------------------------------------------- 37 | /// ----------------- Safe Hooks ------------------------ 38 | /// ----------------------------------------------------- 39 | /// ----------------------------------------------------- 40 | 41 | /// @notice function that restricts Gnosis Safe interaction 42 | /// to external calls only, and disallows self and delegate calls. 43 | function checkTransaction( 44 | address to, 45 | uint256 value, 46 | bytes memory data, 47 | Enum.Operation operationType, 48 | uint256, 49 | uint256, 50 | uint256, 51 | address, 52 | address payable, 53 | bytes memory, 54 | address 55 | ) external view { 56 | if (to == msg.sender) { 57 | /// only allow self calls to effectively cancel a transaction by 58 | /// using a nonce without any payload and value. 59 | require(data.length == 0 && value == 0, "Guard: no self calls"); 60 | } 61 | /// if delegate calls are allowed, owners or modules could be added 62 | /// or removed outside of the expected flow, and the only way to reason 63 | /// about this is to disallow delegate calls as we cannot prove unknown 64 | /// slots were not written to in the owner or modules mapping 65 | require( 66 | operationType == Enum.Operation.Call, 67 | "Guard: delegate call disallowed" 68 | ); 69 | } 70 | 71 | /// @notice no-op function, required by the Guard interface. 72 | /// No checks needed after the tx has been executed. 73 | /// The pre-checks are enough to ensure the transaction is valid. 74 | function checkAfterExecution(bytes32, bool) external pure {} 75 | } 76 | -------------------------------------------------------------------------------- /src/RecoverySpellFactory.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {RecoverySpell} from "@src/RecoverySpell.sol"; 4 | import {calculateCreate2Address} from "src/utils/Create2Helper.sol"; 5 | 6 | /// @notice factory contract to create new RecoverySpell contracts 7 | /// Contract addresses can be determined in advance with different 8 | /// parameters, salts and parameters. 9 | /// 10 | /// Edge Cases: 11 | /// If the safe address has no bytecode, or is incorrectly 12 | /// specified, then the recovery spell address will be calculated 13 | /// correctly, but it will not actually map to the corresponding 14 | /// contract. 15 | /// 16 | /// Contract Paramters: 17 | /// - salt: a random number used to create the contract address 18 | /// - owners: the new owners of the contract 19 | /// - safe: the address of the safe to recover 20 | /// - threshold: the number of owners required to execute transactions on 21 | // the new safe 22 | /// - recoveryThreshold: the number of owners required to execute recovery 23 | /// transactions on the new safe. If 0, no private keys are needed to recover 24 | /// the safe. 25 | /// - delay: the time required before the recovery transaction can be executed 26 | /// on the new safe. can be 0 to execute immediately 27 | 28 | contract RecoverySpellFactory { 29 | /// @notice emitted when a new recovery spell is created 30 | /// @param recoverySpell the address of the new recovery spell 31 | /// @param safe the address of the safe that is being recovered 32 | /// with the spell 33 | event RecoverySpellCreated( 34 | address indexed recoverySpell, address indexed safe 35 | ); 36 | 37 | /// @notice create a new RecoverySpell contract using CREATE2 38 | /// @param salt the salt used to create the contract 39 | /// @param owners the owners of the contract 40 | /// @param safe to recover with the spell 41 | /// @param threshold of owners required to execute transactions 42 | /// @param recoveryThreshold of owners required to execute recovery transactions 43 | /// @param delay time required before the recovery transaction can be executed 44 | function createRecoverySpell( 45 | bytes32 salt, 46 | address[] memory owners, 47 | address safe, 48 | uint256 threshold, 49 | uint256 recoveryThreshold, 50 | uint256 delay 51 | ) external returns (RecoverySpell recovery) { 52 | _paramChecks(owners, threshold, recoveryThreshold, delay); 53 | /// no checks on parameters as all valid recovery spells are 54 | /// deployed from the factory which will not allow a recovery 55 | /// spell to be created that does not have valid parameters 56 | require(safe.code.length != 0, "RecoverySpell: safe non-existent"); 57 | 58 | /// duplicate owner check 59 | for (uint256 i = 0; i < owners.length; i++) { 60 | /// not touching memory, we only use the stack and transient storage 61 | /// so we can use memory-safe for the assembly block 62 | address owner = owners[i]; 63 | bool found; 64 | assembly ("memory-safe") { 65 | found := tload(owner) 66 | /// save a write to transient storage if the owner is found 67 | if eq(found, 0) { tstore(owner, 1) } 68 | } 69 | 70 | require(!found, "RecoverySpell: Duplicate owner"); 71 | } 72 | 73 | recovery = new RecoverySpell{salt: salt}( 74 | owners, safe, threshold, recoveryThreshold, delay 75 | ); 76 | 77 | emit RecoverySpellCreated(address(recovery), address(safe)); 78 | } 79 | 80 | /// @notice calculate the address of a new RecoverySpell contract 81 | /// @param salt the salt used to create the contract 82 | /// @param safe to recover with the spell 83 | /// @param owners the owners of the contract 84 | /// @param threshold of owners required to execute transactions 85 | /// @param delay time required before the recovery transaction can be executed 86 | function calculateAddress( 87 | bytes32 salt, 88 | address[] memory owners, 89 | address safe, 90 | uint256 threshold, 91 | uint256 recoveryThreshold, 92 | uint256 delay 93 | ) external view returns (address predictedAddress) { 94 | _paramChecks(owners, threshold, recoveryThreshold, delay); 95 | 96 | /// duplicate owner check 97 | for (uint256 i = 0; i < owners.length; i++) { 98 | require( 99 | owners[i].code.length == 0, 100 | "RecoverySpell: Owner cannot be a contract" 101 | ); 102 | 103 | for (uint256 j = i + 1; j < owners.length; j++) { 104 | require( 105 | owners[i] != owners[j], "RecoverySpell: Duplicate owner" 106 | ); 107 | } 108 | } 109 | 110 | predictedAddress = calculateCreate2Address( 111 | address(this), 112 | type(RecoverySpell).creationCode, 113 | abi.encode(owners, safe, threshold, recoveryThreshold, delay), 114 | salt 115 | ); 116 | } 117 | 118 | /// @notice check the parameters of the RecoverySpell contract 119 | /// @param owners the owners of the contract 120 | /// @param threshold of owners required to execute transactions 121 | /// @param recoveryThreshold of owners required to execute recovery transactions 122 | /// @param delay time required before the recovery transaction can be executed 123 | function _paramChecks( 124 | address[] memory owners, 125 | uint256 threshold, 126 | uint256 recoveryThreshold, 127 | uint256 delay 128 | ) private pure { 129 | require( 130 | threshold <= owners.length, 131 | "RecoverySpell: Threshold must be lte number of owners" 132 | ); 133 | require( 134 | recoveryThreshold <= owners.length, 135 | "RecoverySpell: Recovery threshold must be lte number of owners" 136 | ); 137 | 138 | require(threshold != 0, "RecoverySpell: Threshold must be gt 0"); 139 | require(delay <= 365 days, "RecoverySpell: Delay must be lte a year"); 140 | for (uint256 i = 0; i < owners.length; i++) { 141 | require(owners[i] != address(0), "RecoverySpell: Owner cannot be 0"); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/TimelockFactory.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {Timelock} from "src/Timelock.sol"; 4 | import {calculateCreate2Address} from "src/utils/Create2Helper.sol"; 5 | 6 | struct DeploymentParams { 7 | uint256 minDelay; 8 | uint256 expirationPeriod; 9 | address pauser; 10 | uint128 pauseDuration; 11 | address[] hotSigners; 12 | address[] contractAddresses; 13 | bytes4[] selectors; 14 | uint16[] startIndexes; 15 | uint16[] endIndexes; 16 | bytes[][] datas; 17 | bytes32 salt; 18 | } 19 | 20 | /// @notice simple factory contract that creates timelocks 21 | contract TimelockFactory { 22 | /// --------------------------------------------------------- 23 | /// --------------------------------------------------------- 24 | /// ------------------------- EVENT ------------------------- 25 | /// --------------------------------------------------------- 26 | /// --------------------------------------------------------- 27 | 28 | /// @notice Emitted when a call is scheduled as part of operation `id`. 29 | /// @param timelock address of the newly created timelock 30 | /// @param creationTime of the new timelock 31 | /// @param sender that called the contract to create the timelock 32 | event TimelockCreated( 33 | address indexed timelock, uint256 creationTime, address indexed sender 34 | ); 35 | 36 | /// @notice Creates a timelock for a given safe and deployment parameters 37 | function createTimelock(address safe, DeploymentParams memory params) 38 | external 39 | returns (address timelock) 40 | { 41 | timelock = address( 42 | new Timelock{ 43 | salt: keccak256(abi.encodePacked(params.salt, msg.sender)) 44 | }( 45 | safe, 46 | params.minDelay, 47 | params.expirationPeriod, 48 | params.pauser, 49 | params.pauseDuration, 50 | params.hotSigners 51 | ) 52 | ); 53 | 54 | emit TimelockCreated(timelock, block.timestamp, msg.sender); 55 | } 56 | 57 | function timelockCreationCode() external pure returns (bytes memory) { 58 | return type(Timelock).creationCode; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/deploy/SystemDeploy.s.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {MultisigProposal} from 4 | "@forge-proposal-simulator/src/proposals/MultisigProposal.sol"; 5 | import {Addresses} from "@forge-proposal-simulator/addresses/Addresses.sol"; 6 | 7 | import {Guard} from "src/Guard.sol"; 8 | import {Timelock} from "src/Timelock.sol"; 9 | import {BytesHelper} from "src/BytesHelper.sol"; 10 | import {TimelockFactory} from "src/TimelockFactory.sol"; 11 | import {InstanceDeployer} from "src/InstanceDeployer.sol"; 12 | import {AddressCalculation} from "src/views/AddressCalculation.sol"; 13 | import {RecoverySpellFactory} from "src/RecoverySpellFactory.sol"; 14 | 15 | function matchPattern(bytes memory data, bytes4 pattern) 16 | pure 17 | returns (uint256) 18 | { 19 | require(data.length >= 4, "Data length is less than pattern length"); 20 | 21 | for (uint256 i = 0; i <= data.length - 4; i++) { 22 | bool isMatch = true; 23 | for (uint256 j = 0; j < 4; j++) { 24 | if (data[i + j] != pattern[j]) { 25 | isMatch = false; 26 | break; 27 | } 28 | } 29 | if (isMatch) { 30 | return i + 1; 31 | } 32 | } 33 | return 0; 34 | } 35 | 36 | /// @notice system deployment contract 37 | /// all contracts are permissionless 38 | /// DO_PRINT=false DO_BUILD=false DO_RUN=false DO_DEPLOY=true DO_VALIDATE=true forge script src/deploy/SystemDeploy.s.sol:SystemDeploy --fork-url base -vvvvv 39 | contract SystemDeploy is MultisigProposal { 40 | using BytesHelper for bytes; 41 | 42 | bytes32 public salt = 43 | 0x0000000000000000000000000000000000000000000000000000000000003afe; 44 | bytes4 public pattern = 0xa2646970; 45 | 46 | constructor() { 47 | uint256[] memory chainIds = new uint256[](5); 48 | chainIds[0] = 1; 49 | chainIds[1] = 8453; 50 | chainIds[2] = 84532; 51 | chainIds[3] = 11155420; 52 | chainIds[4] = 10; 53 | addresses = new Addresses("./addresses", chainIds); 54 | } 55 | 56 | function name() public pure override returns (string memory) { 57 | return "SYS_DEPLOY"; 58 | } 59 | 60 | function description() public pure override returns (string memory) { 61 | return "Deploy Factories, Instance Deployer, Guard and View contracts"; 62 | } 63 | 64 | function deploy() public override { 65 | if (!addresses.isAddressSet("TIMELOCK_FACTORY")) { 66 | TimelockFactory factory = new TimelockFactory{salt: salt}(); 67 | addresses.addAddress("TIMELOCK_FACTORY", address(factory), true); 68 | } 69 | if (!addresses.isAddressSet("RECOVERY_SPELL_FACTORY")) { 70 | RecoverySpellFactory recoveryFactory = 71 | new RecoverySpellFactory{salt: salt}(); 72 | addresses.addAddress( 73 | "RECOVERY_SPELL_FACTORY", address(recoveryFactory), true 74 | ); 75 | } 76 | if (!addresses.isAddressSet("GUARD")) { 77 | Guard guard = new Guard{salt: salt}(); 78 | addresses.addAddress("GUARD", address(guard), true); 79 | } 80 | if (!addresses.isAddressSet("INSTANCE_DEPLOYER")) { 81 | InstanceDeployer deployer = new InstanceDeployer{salt: salt}( 82 | addresses.getAddress("SAFE_FACTORY"), 83 | addresses.getAddress("SAFE_LOGIC"), 84 | addresses.getAddress("TIMELOCK_FACTORY"), 85 | addresses.getAddress("GUARD"), 86 | addresses.getAddress("MULTICALL3") 87 | ); 88 | 89 | addresses.addAddress("INSTANCE_DEPLOYER", address(deployer), true); 90 | } 91 | if (!addresses.isAddressSet("ADDRESS_CALCULATION")) { 92 | AddressCalculation addressCalculation = new AddressCalculation{ 93 | salt: salt 94 | }(addresses.getAddress("INSTANCE_DEPLOYER")); 95 | 96 | addresses.addAddress( 97 | "ADDRESS_CALCULATION", address(addressCalculation), true 98 | ); 99 | } 100 | } 101 | 102 | function validate() public view override { 103 | if (addresses.isAddressSet("TIMELOCK_FACTORY")) { 104 | address factory = addresses.getAddress("TIMELOCK_FACTORY"); 105 | uint256 endIndex = matchPattern(factory.code, pattern); 106 | endIndex = endIndex == 0 ? factory.code.length - 1 : endIndex - 1; 107 | assertEq( 108 | keccak256(factory.code.sliceBytes(0, endIndex)), 109 | keccak256( 110 | type(TimelockFactory).runtimeCode.sliceBytes(0, endIndex) 111 | ), 112 | "Incorrect TimelockFactory Bytecode" 113 | ); 114 | 115 | address guard = addresses.getAddress("GUARD"); 116 | endIndex = matchPattern(guard.code, pattern); 117 | endIndex = endIndex == 0 ? guard.code.length - 1 : endIndex - 1; 118 | assertEq( 119 | keccak256(guard.code.sliceBytes(0, endIndex)), 120 | keccak256(type(Guard).runtimeCode.sliceBytes(0, endIndex)), 121 | "Incorrect Guard Bytecode" 122 | ); 123 | 124 | address recoverySpellFactory = 125 | addresses.getAddress("RECOVERY_SPELL_FACTORY"); 126 | endIndex = matchPattern(recoverySpellFactory.code, pattern); 127 | endIndex = endIndex == 0 128 | ? recoverySpellFactory.code.length - 1 129 | : endIndex - 1; 130 | assertEq( 131 | keccak256(recoverySpellFactory.code.sliceBytes(0, endIndex)), 132 | keccak256( 133 | type(RecoverySpellFactory).runtimeCode.sliceBytes( 134 | 0, endIndex 135 | ) 136 | ), 137 | "Incorrect RecoverySpellFactory Bytecode" 138 | ); 139 | 140 | /// cannot check bytecode, following error is thrown when trying: 141 | /// `"runtimeCode" is not available for contracts containing 142 | /// immutable variables.` 143 | InstanceDeployer deployer = 144 | InstanceDeployer(addresses.getAddress("INSTANCE_DEPLOYER")); 145 | 146 | assertEq( 147 | deployer.safeProxyFactory(), 148 | addresses.getAddress("SAFE_FACTORY"), 149 | "incorrect safe proxy factory" 150 | ); 151 | assertEq( 152 | deployer.safeProxyLogic(), 153 | addresses.getAddress("SAFE_LOGIC"), 154 | "incorrect safe logic contract" 155 | ); 156 | assertEq( 157 | deployer.timelockFactory(), 158 | addresses.getAddress("TIMELOCK_FACTORY"), 159 | "incorrect timelock factory" 160 | ); 161 | assertEq( 162 | deployer.guard(), 163 | addresses.getAddress("GUARD"), 164 | "incorrect GUARD" 165 | ); 166 | assertEq( 167 | deployer.multicall3(), 168 | addresses.getAddress("MULTICALL3"), 169 | "incorrect MULTICALL3" 170 | ); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/interface/CErc20Interface.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | interface CErc20Interface { 4 | function mint(uint256 mintAmount) external returns (uint256); 5 | } 6 | -------------------------------------------------------------------------------- /src/interface/CEtherInterface.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | interface CEtherInterface { 4 | function mint() external; 5 | } 6 | -------------------------------------------------------------------------------- /src/interface/IMulticall3.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | interface IMulticall3 { 4 | struct Call3 { 5 | // Target contract to call. 6 | address target; 7 | // If false, the entire call will revert if the call fails. 8 | bool allowFailure; 9 | // Data to call on the target contract. 10 | bytes callData; 11 | } 12 | 13 | struct Result { 14 | // True if the call succeeded, false otherwise. 15 | bool success; 16 | // Return data if the call succeeded, or revert data if the call reverted. 17 | bytes returnData; 18 | } 19 | 20 | /// @notice Aggregate calls, ensuring each returns success if required 21 | /// @param calls An array of Call3 structs 22 | /// @return returnData An array of Result structs 23 | function aggregate3(Call3[] calldata calls) 24 | external 25 | payable 26 | returns (Result[] memory returnData); 27 | } 28 | -------------------------------------------------------------------------------- /src/interface/WETH9.sol: -------------------------------------------------------------------------------- 1 | pragma solidity >=0.5.0; 2 | 3 | interface WETH9 { 4 | function deposit() external; 5 | function withdraw(uint256 wad) external; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/Constants.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | /// @dev timestamp indicating that an operation is done 4 | uint256 constant _DONE_TIMESTAMP = uint256(1); 5 | 6 | /// @dev minimum delay for timelocked operations 7 | uint256 constant MIN_DELAY = 1 days; 8 | 9 | /// @dev maximum delay for timelocked operations 10 | uint256 constant MAX_DELAY = 30 days; 11 | 12 | /// @dev maximum number of timelocked operations scheduled at the same time 13 | /// @dev this is to prevent the contract from running out of gas when the pause 14 | /// function is called 15 | uint256 constant MAX_PROPOSAL_COUNT = 100; 16 | -------------------------------------------------------------------------------- /src/utils/Create2Helper.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | struct Create2Params { 4 | address creator; 5 | bytes creationCode; 6 | bytes constructorParams; 7 | bytes32 salt; 8 | } 9 | 10 | function calculateCreate2Address( 11 | address creator, 12 | bytes memory creationCode, 13 | bytes memory constructorParams, 14 | bytes32 salt 15 | ) pure returns (address) { 16 | return address( 17 | uint160( 18 | uint256( 19 | keccak256( 20 | abi.encodePacked( 21 | bytes1(0xff), 22 | creator, 23 | salt, 24 | keccak256( 25 | abi.encodePacked(creationCode, constructorParams) 26 | ) 27 | ) 28 | ) 29 | ) 30 | ) 31 | ); 32 | } 33 | 34 | function calculateCreate2Address(Create2Params memory params) 35 | pure 36 | returns (address) 37 | { 38 | return address( 39 | uint160( 40 | uint256( 41 | keccak256( 42 | abi.encodePacked( 43 | bytes1(0xff), 44 | params.creator, 45 | params.salt, 46 | keccak256( 47 | abi.encodePacked( 48 | params.creationCode, params.constructorParams 49 | ) 50 | ) 51 | ) 52 | ) 53 | ) 54 | ) 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/views/AddressCalculation.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {SafeProxyFactory} from "@safe/proxies/SafeProxyFactory.sol"; 4 | import {SafeProxy} from "@safe/proxies/SafeProxy.sol"; 5 | 6 | import {Timelock} from "src/Timelock.sol"; 7 | import {TimelockFactory, DeploymentParams} from "src/TimelockFactory.sol"; 8 | import { 9 | calculateCreate2Address, Create2Params 10 | } from "src/utils/Create2Helper.sol"; 11 | import { 12 | InstanceDeployer, 13 | NewInstance, 14 | SystemInstance 15 | } from "src/InstanceDeployer.sol"; 16 | 17 | contract AddressCalculation { 18 | /// @notice instance deployer 19 | address public immutable instanceDeployer; 20 | 21 | constructor(address _instanceDeployer) { 22 | instanceDeployer = _instanceDeployer; 23 | } 24 | 25 | /// @notice calculate address with safety checks, ensuring the address has 26 | /// not already been created by the respective safe and timelock factories 27 | /// @param instance configuration information 28 | function calculateAddress(NewInstance memory instance) 29 | external 30 | view 31 | returns (SystemInstance memory walletInstance) 32 | { 33 | /// important check: 34 | /// - recovery spells should have no bytecode 35 | /// - this is a duplicate check, however there is no harm in being safe 36 | for (uint256 i = 0; i < instance.recoverySpells.length; i++) { 37 | require( 38 | instance.recoverySpells[i].code.length == 0, 39 | "InstanceDeployer: recovery spell has bytecode" 40 | ); 41 | } 42 | 43 | walletInstance = calculateAddressUnsafe(instance); 44 | 45 | /// if the safe does not exist, then there should be no need to check 46 | /// recovery spell addresses because they will not be able to be 47 | /// created from the recovery spell factory if the safe does not exist. 48 | require( 49 | address(walletInstance.safe).code.length == 0, 50 | "InstanceDeployer: safe already created" 51 | ); 52 | require( 53 | address(walletInstance.timelock).code.length == 0, 54 | "InstanceDeployer: timelock already created" 55 | ); 56 | } 57 | 58 | /// @notice calculate address without safety checks 59 | /// WARNING: only use this if you know what you are doing and are an 60 | /// advanced user. 61 | /// @param instance configuration information 62 | function calculateAddressUnsafe(NewInstance memory instance) 63 | public 64 | view 65 | returns (SystemInstance memory walletInstance) 66 | { 67 | address[] memory factoryOwner = new address[](1); 68 | factoryOwner[0] = instanceDeployer; 69 | 70 | bytes memory safeInitdata = abi.encodeWithSignature( 71 | "setup(address[],uint256,address,bytes,address,address,uint256,address)", 72 | factoryOwner, 73 | 1, 74 | /// no to address because there are no external actions on 75 | /// initialization 76 | address(0), 77 | /// no data because there are no external actions on initialization 78 | "", 79 | /// no fallback handler allowed by Guard 80 | address(0), 81 | /// no payment token 82 | address(0), 83 | /// no payment amount 84 | 0, 85 | /// no payment receiver because no payment amount 86 | address(0) 87 | ); 88 | 89 | { 90 | uint256 creationSalt = uint256( 91 | keccak256( 92 | abi.encode( 93 | instance.owners, 94 | instance.threshold, 95 | instance.timelockParams.minDelay, 96 | instance.timelockParams.expirationPeriod, 97 | instance.timelockParams.pauser, 98 | instance.timelockParams.pauseDuration, 99 | instance.timelockParams.hotSigners 100 | ) 101 | ) 102 | ); 103 | 104 | /// timelock salt is the result of the all params, so no one can 105 | /// front-run creation of the timelock with the same address on other 106 | /// chains 107 | instance.timelockParams.salt = bytes32(creationSalt); 108 | 109 | bytes32 salt = keccak256( 110 | abi.encodePacked(keccak256(safeInitdata), creationSalt) 111 | ); 112 | address safeProxyFactory = 113 | InstanceDeployer(instanceDeployer).safeProxyFactory(); 114 | 115 | walletInstance.safe = SafeProxy( 116 | payable( 117 | calculateCreate2Address( 118 | safeProxyFactory, 119 | SafeProxyFactory(safeProxyFactory).proxyCreationCode(), 120 | abi.encodePacked( 121 | uint256( 122 | uint160( 123 | InstanceDeployer(instanceDeployer) 124 | .safeProxyLogic() 125 | ) 126 | ) 127 | ), 128 | salt 129 | ) 130 | ) 131 | ); 132 | } 133 | address timelockFactory = 134 | InstanceDeployer(instanceDeployer).timelockFactory(); 135 | 136 | Create2Params memory params; 137 | params.creator = timelockFactory; 138 | params.creationCode = 139 | TimelockFactory(timelockFactory).timelockCreationCode(); 140 | 141 | params.constructorParams = abi.encode( 142 | walletInstance.safe, 143 | instance.timelockParams.minDelay, 144 | instance.timelockParams.expirationPeriod, 145 | instance.timelockParams.pauser, 146 | instance.timelockParams.pauseDuration, 147 | instance.timelockParams.hotSigners 148 | ); 149 | params.salt = keccak256( 150 | abi.encodePacked(instance.timelockParams.salt, instanceDeployer) 151 | ); 152 | 153 | walletInstance.timelock = 154 | Timelock(payable(calculateCreate2Address(params))); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /test/integration/AddressCalculation.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import "test/utils/SystemIntegrationFixture.sol"; 4 | 5 | contract AddressCalculationIntegrationTest is SystemIntegrationFixture { 6 | function testSetup() public view { 7 | assertEq( 8 | addressCalculation.instanceDeployer(), 9 | address(deployer), 10 | "Instance deployer should match" 11 | ); 12 | } 13 | 14 | function testCalculateAddressMatchesCreatedAddresses() public { 15 | address[] memory recoverySpell = new address[](1); 16 | recoverySpell[0] = recoverySpellAddress; 17 | 18 | /// 2 / 3 multisig 19 | uint256 threshold = 2; 20 | 21 | DeploymentParams memory params = DeploymentParams({ 22 | minDelay: MINIMUM_DELAY + 1, 23 | expirationPeriod: EXPIRATION_PERIOD, 24 | pauser: guardian, 25 | pauseDuration: PAUSE_DURATION, 26 | hotSigners: hotSigners, 27 | contractAddresses: new address[](0), 28 | selectors: new bytes4[](0), 29 | startIndexes: new uint16[](0), 30 | endIndexes: new uint16[](0), 31 | datas: new bytes[][](0), 32 | salt: salt 33 | }); 34 | 35 | NewInstance memory instance = NewInstance({ 36 | owners: owners, 37 | threshold: threshold, 38 | recoverySpells: recoverySpell, 39 | timelockParams: params 40 | }); 41 | 42 | SystemInstance memory expectedContracts = 43 | addressCalculation.calculateAddress(instance); 44 | 45 | vm.prank(HOT_SIGNER_ONE); 46 | SystemInstance memory actualContracts = 47 | deployer.createSystemInstance(instance); 48 | 49 | assertEq( 50 | address(expectedContracts.safe), 51 | address(actualContracts.safe), 52 | "Safe address should match" 53 | ); 54 | assertEq( 55 | address(expectedContracts.timelock), 56 | address(actualContracts.timelock), 57 | "Timelock address should match" 58 | ); 59 | } 60 | 61 | function testRecoverySpellAddressesNotCalculated() public { 62 | address[] memory recoverySpell = new address[](1); 63 | recoverySpell[0] = recoverySpellAddress; 64 | 65 | /// 2 / 3 multisig 66 | uint256 threshold = 2; 67 | 68 | DeploymentParams memory params = DeploymentParams({ 69 | minDelay: MINIMUM_DELAY + 1, 70 | expirationPeriod: EXPIRATION_PERIOD, 71 | pauser: guardian, 72 | pauseDuration: PAUSE_DURATION, 73 | hotSigners: hotSigners, 74 | contractAddresses: new address[](0), 75 | selectors: new bytes4[](0), 76 | startIndexes: new uint16[](0), 77 | endIndexes: new uint16[](0), 78 | datas: new bytes[][](0), 79 | salt: salt 80 | }); 81 | 82 | NewInstance memory instance = NewInstance({ 83 | owners: owners, 84 | threshold: threshold, 85 | recoverySpells: recoverySpell, 86 | timelockParams: params 87 | }); 88 | 89 | SystemInstance memory expectedContracts = 90 | addressCalculation.calculateAddress(instance); 91 | 92 | /// remove recovery spell 93 | instance.recoverySpells = new address[](0); 94 | 95 | vm.prank(HOT_SIGNER_ONE); 96 | SystemInstance memory actualContracts = 97 | deployer.createSystemInstance(instance); 98 | 99 | assertEq( 100 | address(expectedContracts.safe), 101 | address(actualContracts.safe), 102 | "Safe address should match" 103 | ); 104 | assertEq( 105 | address(expectedContracts.timelock), 106 | address(actualContracts.timelock), 107 | "Timelock address should match" 108 | ); 109 | } 110 | 111 | function testCalculateAddressSafeFailsSafeAlreadyDeployed() public { 112 | address[] memory recoverySpell = new address[](1); 113 | recoverySpell[0] = recoverySpellAddress; 114 | 115 | /// 2 / 3 multisig 116 | uint256 threshold = 2; 117 | 118 | DeploymentParams memory params = DeploymentParams({ 119 | minDelay: MINIMUM_DELAY + 1, 120 | expirationPeriod: EXPIRATION_PERIOD, 121 | pauser: guardian, 122 | pauseDuration: PAUSE_DURATION, 123 | hotSigners: hotSigners, 124 | contractAddresses: new address[](0), 125 | selectors: new bytes4[](0), 126 | startIndexes: new uint16[](0), 127 | endIndexes: new uint16[](0), 128 | datas: new bytes[][](0), 129 | salt: salt 130 | }); 131 | 132 | NewInstance memory instance = NewInstance({ 133 | owners: owners, 134 | threshold: threshold, 135 | recoverySpells: recoverySpell, 136 | timelockParams: params 137 | }); 138 | 139 | /// remove recovery spell 140 | instance.recoverySpells = new address[](0); 141 | 142 | vm.prank(HOT_SIGNER_ONE); 143 | deployer.createSystemInstance(instance); 144 | 145 | vm.expectRevert("InstanceDeployer: safe already created"); 146 | addressCalculation.calculateAddress(instance); 147 | } 148 | 149 | function testCalculateAddressTimelockFailsTimelockAlreadyDeployed() 150 | public 151 | { 152 | address[] memory recoverySpell = new address[](1); 153 | recoverySpell[0] = recoverySpellAddress; 154 | 155 | /// 2 / 3 multisig 156 | uint256 threshold = 2; 157 | 158 | DeploymentParams memory params = DeploymentParams({ 159 | minDelay: MINIMUM_DELAY + 1, 160 | expirationPeriod: EXPIRATION_PERIOD, 161 | pauser: guardian, 162 | pauseDuration: PAUSE_DURATION, 163 | hotSigners: hotSigners, 164 | contractAddresses: new address[](0), 165 | selectors: new bytes4[](0), 166 | startIndexes: new uint16[](0), 167 | endIndexes: new uint16[](0), 168 | datas: new bytes[][](0), 169 | salt: salt 170 | }); 171 | 172 | NewInstance memory instance = NewInstance({ 173 | owners: owners, 174 | threshold: threshold, 175 | recoverySpells: recoverySpell, 176 | timelockParams: params 177 | }); 178 | 179 | /// remove recovery spell 180 | instance.recoverySpells = new address[](0); 181 | 182 | SystemInstance memory expectedContracts = 183 | addressCalculation.calculateAddress(instance); 184 | 185 | vm.etch(address(expectedContracts.timelock), hex"3afe"); 186 | 187 | vm.expectRevert("InstanceDeployer: timelock already created"); 188 | addressCalculation.calculateAddress(instance); 189 | } 190 | 191 | function testTimelockBytecodeUnset() public { 192 | address[] memory recoverySpell = new address[](1); 193 | recoverySpell[0] = recoverySpellAddress; 194 | 195 | /// 2 / 3 multisig 196 | uint256 threshold = 2; 197 | 198 | DeploymentParams memory params = DeploymentParams({ 199 | minDelay: MINIMUM_DELAY + 1, 200 | expirationPeriod: EXPIRATION_PERIOD, 201 | pauser: guardian, 202 | pauseDuration: PAUSE_DURATION, 203 | hotSigners: hotSigners, 204 | contractAddresses: new address[](0), 205 | selectors: new bytes4[](0), 206 | startIndexes: new uint16[](0), 207 | endIndexes: new uint16[](0), 208 | datas: new bytes[][](0), 209 | salt: salt 210 | }); 211 | 212 | NewInstance memory instance = NewInstance({ 213 | owners: owners, 214 | threshold: threshold, 215 | recoverySpells: recoverySpell, 216 | timelockParams: params 217 | }); 218 | 219 | /// remove recovery spell 220 | instance.recoverySpells = new address[](0); 221 | 222 | vm.prank(HOT_SIGNER_ONE); 223 | SystemInstance memory contracts = 224 | deployer.createSystemInstance(instance); 225 | 226 | /// remove timelock and safe bytecode 227 | vm.etch(address(contracts.safe), ""); 228 | vm.etch(address(contracts.timelock), ""); 229 | 230 | /// call succeeds 231 | addressCalculation.calculateAddress(instance); 232 | } 233 | 234 | function testCalculateAddressBytecodeRecoverySpellFails() public { 235 | address[] memory recoverySpell = new address[](1); 236 | recoverySpell[0] = recoverySpellAddress; 237 | 238 | /// 2 / 3 multisig 239 | uint256 threshold = 2; 240 | 241 | DeploymentParams memory params = DeploymentParams({ 242 | minDelay: MINIMUM_DELAY + 1, 243 | expirationPeriod: EXPIRATION_PERIOD, 244 | pauser: guardian, 245 | pauseDuration: PAUSE_DURATION, 246 | hotSigners: hotSigners, 247 | contractAddresses: new address[](0), 248 | selectors: new bytes4[](0), 249 | startIndexes: new uint16[](0), 250 | endIndexes: new uint16[](0), 251 | datas: new bytes[][](0), 252 | salt: salt 253 | }); 254 | 255 | NewInstance memory instance = NewInstance({ 256 | owners: owners, 257 | threshold: threshold, 258 | recoverySpells: recoverySpell, 259 | timelockParams: params 260 | }); 261 | 262 | vm.etch(recoverySpellAddress, hex"3afe"); 263 | 264 | vm.expectRevert("InstanceDeployer: recovery spell has bytecode"); 265 | addressCalculation.calculateAddress(instance); 266 | } 267 | 268 | function testHotSignersDifferentCreatesDifferentSystemAddresses() public { 269 | address[] memory recoverySpell = new address[](1); 270 | recoverySpell[0] = recoverySpellAddress; 271 | 272 | /// 2 / 3 multisig 273 | uint256 threshold = 2; 274 | 275 | DeploymentParams memory params1 = DeploymentParams({ 276 | minDelay: MINIMUM_DELAY + 1, 277 | expirationPeriod: EXPIRATION_PERIOD, 278 | pauser: guardian, 279 | pauseDuration: PAUSE_DURATION, 280 | hotSigners: hotSigners, 281 | contractAddresses: new address[](0), 282 | selectors: new bytes4[](0), 283 | startIndexes: new uint16[](0), 284 | endIndexes: new uint16[](0), 285 | datas: new bytes[][](0), 286 | salt: salt 287 | }); 288 | 289 | /// remove a single hot signer, this should completely change the address 290 | hotSigners.pop(); 291 | 292 | DeploymentParams memory params2 = DeploymentParams({ 293 | minDelay: MINIMUM_DELAY + 1, 294 | expirationPeriod: EXPIRATION_PERIOD, 295 | pauser: guardian, 296 | pauseDuration: PAUSE_DURATION, 297 | hotSigners: hotSigners, 298 | contractAddresses: new address[](0), 299 | selectors: new bytes4[](0), 300 | startIndexes: new uint16[](0), 301 | endIndexes: new uint16[](0), 302 | datas: new bytes[][](0), 303 | salt: salt 304 | }); 305 | 306 | NewInstance memory instance1 = NewInstance({ 307 | owners: owners, 308 | threshold: threshold, 309 | recoverySpells: recoverySpell, 310 | timelockParams: params1 311 | }); 312 | NewInstance memory instance2 = NewInstance({ 313 | owners: owners, 314 | threshold: threshold, 315 | recoverySpells: recoverySpell, 316 | timelockParams: params2 317 | }); 318 | 319 | instance2.timelockParams.hotSigners = hotSigners; 320 | 321 | SystemInstance memory contracts1 = 322 | addressCalculation.calculateAddress(instance1); 323 | SystemInstance memory contracts2 = 324 | addressCalculation.calculateAddress(instance2); 325 | 326 | assertNotEq( 327 | address(contracts1.safe), 328 | address(contracts2.safe), 329 | "Safe addresses should not match" 330 | ); 331 | assertNotEq( 332 | address(contracts1.timelock), 333 | address(contracts2.timelock), 334 | "Timelock addresses should not match" 335 | ); 336 | } 337 | 338 | function testCalculateAddressUnsafe() public { 339 | address[] memory recoverySpell = new address[](1); 340 | recoverySpell[0] = recoverySpellAddress; 341 | 342 | /// 2 / 3 multisig 343 | uint256 threshold = 2; 344 | 345 | DeploymentParams memory params = DeploymentParams({ 346 | minDelay: MINIMUM_DELAY + 1, 347 | expirationPeriod: EXPIRATION_PERIOD, 348 | pauser: guardian, 349 | pauseDuration: PAUSE_DURATION, 350 | hotSigners: hotSigners, 351 | contractAddresses: new address[](0), 352 | selectors: new bytes4[](0), 353 | startIndexes: new uint16[](0), 354 | endIndexes: new uint16[](0), 355 | datas: new bytes[][](0), 356 | salt: salt 357 | }); 358 | 359 | NewInstance memory instance = NewInstance({ 360 | owners: owners, 361 | threshold: threshold, 362 | recoverySpells: recoverySpell, 363 | timelockParams: params 364 | }); 365 | 366 | SystemInstance memory expectedContracts = 367 | addressCalculation.calculateAddressUnsafe(instance); 368 | 369 | vm.prank(HOT_SIGNER_ONE); 370 | SystemInstance memory actualContracts = 371 | deployer.createSystemInstance(instance); 372 | 373 | assertEq( 374 | address(expectedContracts.safe), 375 | address(actualContracts.safe), 376 | "Safe address should match" 377 | ); 378 | assertEq( 379 | address(expectedContracts.timelock), 380 | address(actualContracts.timelock), 381 | "Timelock address should match" 382 | ); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /test/integration/RecoverySpells.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {ECDSA} from 4 | "@openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; 5 | 6 | import "test/utils/SystemIntegrationFixture.sol"; 7 | 8 | contract RecoverySpellsIntegrationTest is SystemIntegrationFixture { 9 | using BytesHelper for bytes; 10 | 11 | function testCreateAddAndUseCounterfactualRecoverySpellRecoveryThresholdTwo( 12 | ) public { 13 | assertTrue( 14 | safe.isModuleEnabled(address(timelock)), "timelock not a module" 15 | ); 16 | assertTrue( 17 | safe.isModuleEnabled(recoverySpellAddress), 18 | "recovery spell not a module" 19 | ); 20 | 21 | /// create spell 22 | assertEq( 23 | address( 24 | recoveryFactory.createRecoverySpell( 25 | recoverySalt, 26 | recoveryOwners, 27 | address(safe), 28 | recoveryThreshold, 29 | RECOVERY_THRESHOLD_OWNERS, 30 | recoveryDelay 31 | ) 32 | ), 33 | recoverySpellAddress, 34 | "recovery spell address mismatch" 35 | ); 36 | 37 | RecoverySpell spell = RecoverySpell(recoverySpellAddress); 38 | assertEq(spell.delay(), recoveryDelay, "delay mismatch"); 39 | 40 | vm.warp(spell.delay() + block.timestamp + 1); 41 | 42 | /// sign recovery transaction 43 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 44 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 45 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 46 | 47 | bytes32 digest = spell.getDigest(); 48 | 49 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 50 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 51 | } 52 | 53 | vm.expectEmit(true, true, true, true, address(spell)); 54 | emit SafeRecovered(block.timestamp); 55 | 56 | /// execute recovery transaction 57 | spell.executeRecovery(address(1), v, r, s); 58 | 59 | assertFalse( 60 | safe.isModuleEnabled(recoverySpellAddress), 61 | "recovery spell should be removed as a module after execution" 62 | ); 63 | 64 | for (uint256 i = 0; i < owners.length; i++) { 65 | assertFalse( 66 | safe.isOwner(owners[i]), 67 | "owner should be removed after recovery" 68 | ); 69 | } 70 | 71 | for (uint256 i = 0; i < owners.length; i++) { 72 | assertTrue( 73 | safe.isOwner(recoveryOwners[i]), 74 | "recovery owners should be added after recovery" 75 | ); 76 | } 77 | 78 | assertEq(spell.getOwners().length, 0, "owners should be empty"); 79 | assertEq( 80 | spell.recoveryInitiated(), 81 | type(uint256).max, 82 | "recovery initiated should be uint max" 83 | ); 84 | 85 | assertEq(safe.getThreshold(), recoveryThreshold, "threshold incorrect"); 86 | } 87 | 88 | function testRecoverySpellRotatesAllSigners() 89 | public 90 | returns (RecoverySpell recovery) 91 | { 92 | recovery = recoveryFactory.createRecoverySpell( 93 | recoverySalt, 94 | recoveryOwners, 95 | address(safe), 96 | recoveryThreshold, 97 | RECOVERY_THRESHOLD_OWNERS, 98 | recoveryDelay 99 | ); 100 | 101 | assertEq( 102 | recoverySpellAddress, 103 | address(recovery), 104 | "recovery spell address incorrect" 105 | ); 106 | assertTrue( 107 | address(recovery).code.length != 0, "recovery spell not created" 108 | ); 109 | 110 | assertEq( 111 | recovery.recoveryInitiated(), 112 | block.timestamp, 113 | "recovery not initiated" 114 | ); 115 | 116 | vm.warp(block.timestamp + recoveryDelay + 1); 117 | 118 | /// sign recovery transaction 119 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 120 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 121 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 122 | 123 | bytes32 digest = recovery.getDigest(); 124 | 125 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 126 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 127 | } 128 | 129 | vm.expectEmit(true, true, true, true, address(recovery)); 130 | emit SafeRecovered(block.timestamp); 131 | 132 | /// execute recovery transaction 133 | recovery.executeRecovery(address(1), v, r, s); 134 | 135 | assertFalse( 136 | safe.isModuleEnabled(recoverySpellAddress), 137 | "recovery spell should be removed after execution" 138 | ); 139 | 140 | for (uint256 i = 0; i < owners.length; i++) { 141 | assertFalse( 142 | safe.isOwner(owners[i]), 143 | "owner should be removed after recovery" 144 | ); 145 | } 146 | 147 | for (uint256 i = 0; i < recoveryOwners.length; i++) { 148 | assertTrue( 149 | safe.isOwner(recoveryOwners[i]), 150 | "recovery owner should be an owner" 151 | ); 152 | } 153 | 154 | assertEq(recovery.getOwners().length, 0, "owners should be empty"); 155 | assertEq( 156 | recovery.recoveryInitiated(), 157 | type(uint256).max, 158 | "recovery initiated should be uint max" 159 | ); 160 | 161 | assertEq(safe.getThreshold(), recoveryThreshold, "threshold incorrect"); 162 | } 163 | 164 | function testRecoverySpellRecoverFailsNotEnoughSignatures() public { 165 | uint256 recoveryThresholdOwners = 2; 166 | recoverySpellAddress = recoveryFactory.calculateAddress( 167 | recoverySalt, 168 | recoveryOwners, 169 | address(safe), 170 | recoveryThreshold, 171 | recoveryThresholdOwners, 172 | recoveryDelay 173 | ); 174 | 175 | RecoverySpell recovery = recoveryFactory.createRecoverySpell( 176 | recoverySalt, 177 | recoveryOwners, 178 | address(safe), 179 | recoveryThreshold, 180 | recoveryThresholdOwners, 181 | recoveryDelay 182 | ); 183 | 184 | vm.warp(block.timestamp + recoveryDelay - 1); 185 | 186 | vm.expectRevert("RecoverySpell: Recovery not ready"); 187 | recovery.executeRecovery( 188 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 189 | ); 190 | 191 | /// now timestamp exactly at recovery delay 192 | vm.warp(block.timestamp + 1); 193 | vm.expectRevert("RecoverySpell: Recovery not ready"); 194 | recovery.executeRecovery( 195 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 196 | ); 197 | 198 | /// now timestamp is exactly 1 second past recovery delay and recovery can commence 199 | vm.warp(block.timestamp + 1); 200 | 201 | vm.expectRevert("RecoverySpell: Not enough signatures"); 202 | recovery.executeRecovery( 203 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 204 | ); 205 | 206 | vm.expectRevert("RecoverySpell: Invalid signature parameters"); 207 | recovery.executeRecovery( 208 | address(1), new uint8[](1), new bytes32[](0), new bytes32[](0) 209 | ); 210 | 211 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 212 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 213 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 214 | 215 | bytes32 digest = recovery.getDigest(); 216 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 217 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 218 | } 219 | 220 | /// final signature duplicate 221 | v[v.length - 1] = v[v.length - 2]; 222 | r[r.length - 1] = r[r.length - 2]; 223 | s[s.length - 1] = s[s.length - 2]; 224 | 225 | vm.expectRevert("RecoverySpell: Invalid signature"); 226 | recovery.executeRecovery(address(1), v, r, s); 227 | 228 | v[0] += 2; 229 | vm.expectRevert(ECDSA.ECDSAInvalidSignature.selector); 230 | recovery.executeRecovery(address(1), v, r, s); 231 | } 232 | 233 | function testExecuteRecoveryPostRecoveryFails() public { 234 | RecoverySpell recovery = 235 | testRecoverySpellNoSignersNeededRotatesSafeSigners(); 236 | 237 | vm.expectRevert("RecoverySpell: Already recovered"); 238 | recovery.executeRecovery( 239 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 240 | ); 241 | } 242 | 243 | function testAddRecoverySpellNoSignersNeeded() 244 | public 245 | returns (RecoverySpell recovery) 246 | { 247 | uint256 recoveryThresholdOwners = 0; 248 | recovery = RecoverySpell( 249 | recoveryFactory.calculateAddress( 250 | recoverySalt, 251 | recoveryOwners, 252 | address(safe), 253 | recoveryThreshold, 254 | recoveryThresholdOwners, 255 | recoveryDelay 256 | ) 257 | ); 258 | 259 | assertTrue(address(recovery).code.length == 0, "recovery spell created"); 260 | 261 | /// timelock calls multisig, multisig calls multisig 262 | 263 | bytes memory calldatas = abi.encodeWithSelector( 264 | ModuleManager.execTransactionFromModule.selector, 265 | address(safe), 266 | 0, 267 | abi.encodeWithSelector( 268 | ModuleManager.enableModule.selector, address(recovery) 269 | ), 270 | Enum.Operation.Call 271 | ); 272 | bytes memory innerCalldatas = abi.encodeWithSelector( 273 | Timelock.schedule.selector, 274 | address(safe), 275 | 0, 276 | calldatas, 277 | /// salt 278 | bytes32(0), 279 | timelock.minDelay() 280 | ); 281 | 282 | bytes32 transactionHash = safe.getTransactionHash( 283 | address(timelock), 284 | 0, 285 | innerCalldatas, 286 | Enum.Operation.Call, 287 | 0, 288 | 0, 289 | 0, 290 | address(0), 291 | address(0), 292 | safe.nonce() 293 | ); 294 | 295 | bytes memory collatedSignatures = 296 | signTxAllOwners(transactionHash, pk1, pk2, pk3); 297 | 298 | safe.checkNSignatures( 299 | transactionHash, innerCalldatas, collatedSignatures, 3 300 | ); 301 | 302 | safe.execTransaction( 303 | address(timelock), 304 | 0, 305 | innerCalldatas, 306 | Enum.Operation.Call, 307 | 0, 308 | 0, 309 | 0, 310 | address(0), 311 | payable(address(0)), 312 | collatedSignatures 313 | ); 314 | 315 | vm.warp(block.timestamp + timelock.minDelay()); 316 | 317 | timelock.execute(address(safe), 0, calldatas, bytes32(0)); 318 | 319 | assertTrue( 320 | safe.isModuleEnabled(recoverySpellAddress), 321 | "recovery spell should be removed after execution" 322 | ); 323 | assertEq( 324 | timelock.getAllProposals().length, 0, "proposal should be removed" 325 | ); 326 | } 327 | 328 | function testRecoverySpellNoSignersNeededRotatesSafeSigners() 329 | public 330 | returns (RecoverySpell) 331 | { 332 | RecoverySpell recovery = testAddRecoverySpellNoSignersNeeded(); 333 | 334 | RecoverySpell createdRecovery = recoveryFactory.createRecoverySpell( 335 | recoverySalt, 336 | recoveryOwners, 337 | address(safe), 338 | recoveryThreshold, 339 | 0, 340 | recoveryDelay 341 | ); 342 | 343 | assertEq( 344 | address(createdRecovery), 345 | address(recovery), 346 | "expected recovery address not correct" 347 | ); 348 | 349 | assertEq( 350 | recovery.recoveryInitiated(), 351 | block.timestamp, 352 | "recovery not initiated" 353 | ); 354 | 355 | vm.warp(block.timestamp + recoveryDelay + 1); 356 | 357 | recovery.executeRecovery( 358 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 359 | ); 360 | 361 | assertEq(safe.getThreshold(), recoveryThreshold, "quorum not updated"); 362 | assertEq( 363 | safe.getOwners().length, 364 | recoveryOwners.length, 365 | "signer list not rotated" 366 | ); 367 | 368 | for (uint256 i = 0; i < recoveryOwners.length; i++) { 369 | assertTrue( 370 | safe.isOwner(recoveryOwners[i]), 371 | "recovery owner should be an owner" 372 | ); 373 | } 374 | 375 | return recovery; 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /test/integration/SocialRecovery.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import "test/utils/SystemIntegrationFixture.sol"; 4 | 5 | import {generateCalldatas} from "test/utils/NestedArrayHelper.sol"; 6 | 7 | contract SocialRecoveryIntegrationTest is SystemIntegrationFixture { 8 | using BytesHelper for bytes; 9 | 10 | struct RecoveryFuzz { 11 | uint8 currentOwnerLength; 12 | uint8 recoveryOwnerLength; 13 | uint8 recoveryThreshold; 14 | uint8 threshold; 15 | } 16 | 17 | function testRecoverySpellFuzz(RecoveryFuzz memory recoveryFuzz) public { 18 | recoveryFuzz.currentOwnerLength = uint8( 19 | bound( 20 | uint256(recoveryFuzz.currentOwnerLength), 21 | 1, 22 | safe.getOwners().length - 1 23 | ) 24 | ); 25 | recoveryFuzz.recoveryOwnerLength = 26 | uint8(bound(uint256(recoveryFuzz.recoveryOwnerLength), 1, 100)); 27 | recoveryFuzz.threshold = uint8( 28 | bound( 29 | uint256(recoveryFuzz.threshold), 30 | 1, 31 | recoveryFuzz.recoveryOwnerLength 32 | ) 33 | ); 34 | recoveryFuzz.recoveryThreshold = uint8( 35 | bound( 36 | uint256(recoveryFuzz.threshold), 37 | 1, 38 | recoveryFuzz.recoveryOwnerLength 39 | ) 40 | ); 41 | 42 | uint256[] memory recoveryKeys = 43 | new uint256[](recoveryFuzz.recoveryOwnerLength); 44 | 45 | address[] memory newRecoveryOwners = 46 | new address[](recoveryFuzz.recoveryOwnerLength); 47 | 48 | for (uint256 i = 0; i < newRecoveryOwners.length; i++) { 49 | recoveryKeys[i] = uint256(keccak256(abi.encodePacked(i))); 50 | newRecoveryOwners[i] = vm.addr(recoveryKeys[i]); 51 | } 52 | 53 | /// first remove owners until the currentOwnerLength is reached 54 | IMulticall3.Call3[] memory calls3 = new IMulticall3.Call3[]( 55 | safe.getOwners().length - recoveryFuzz.currentOwnerLength + 1 56 | ); 57 | 58 | for ( 59 | uint256 i = 0; 60 | i < safe.getOwners().length - recoveryFuzz.currentOwnerLength; 61 | i++ 62 | ) { 63 | calls3[i] = IMulticall3.Call3({ 64 | target: address(safe), 65 | callData: abi.encodeWithSelector( 66 | OwnerManager.removeOwner.selector, 67 | address(1), 68 | safe.getOwners()[i], 69 | 1 70 | ), 71 | allowFailure: false 72 | }); 73 | } 74 | 75 | address recoveryAddress = recoveryFactory.calculateAddress( 76 | recoverySalt, 77 | newRecoveryOwners, 78 | address(safe), 79 | recoveryFuzz.threshold, 80 | recoveryFuzz.recoveryThreshold, 81 | recoveryDelay 82 | ); 83 | 84 | calls3[calls3.length - 1] = IMulticall3.Call3({ 85 | target: address(safe), 86 | callData: abi.encodeWithSelector( 87 | ModuleManager.enableModule.selector, recoveryAddress 88 | ), 89 | allowFailure: false 90 | }); 91 | 92 | /// remove safe owners, add recovery spell as module 93 | bytes memory calldatas = abi.encodeWithSelector( 94 | ModuleManager.execTransactionFromModule.selector, 95 | multicall, 96 | 0, 97 | abi.encodeWithSelector(IMulticall3.aggregate3.selector, calls3), 98 | Enum.Operation.DelegateCall 99 | ); 100 | 101 | bytes memory innerCalldatas = abi.encodeWithSelector( 102 | Timelock.schedule.selector, 103 | address(safe), 104 | 0, 105 | calldatas, 106 | /// salt 107 | bytes32(0), 108 | timelock.minDelay() 109 | ); 110 | 111 | bytes32 transactionHash = safe.getTransactionHash( 112 | address(timelock), 113 | 0, 114 | innerCalldatas, 115 | Enum.Operation.Call, 116 | 0, 117 | 0, 118 | 0, 119 | address(0), 120 | address(0), 121 | safe.nonce() 122 | ); 123 | 124 | bytes memory collatedSignatures = 125 | signTxAllOwners(transactionHash, pk1, pk2, pk3); 126 | 127 | safe.checkNSignatures( 128 | transactionHash, innerCalldatas, collatedSignatures, 3 129 | ); 130 | 131 | safe.execTransaction( 132 | address(timelock), 133 | 0, 134 | innerCalldatas, 135 | Enum.Operation.Call, 136 | 0, 137 | 0, 138 | 0, 139 | address(0), 140 | payable(address(0)), 141 | collatedSignatures 142 | ); 143 | 144 | vm.warp(block.timestamp + timelock.minDelay()); 145 | 146 | timelock.execute(address(safe), 0, calldatas, bytes32(0)); 147 | 148 | /// then calculate the recovery spell address for the new recoveryOwners 149 | /// then add the new module to the safe 150 | /// then execute the recovery spell 151 | 152 | RecoverySpell recovery = recoveryFactory.createRecoverySpell( 153 | recoverySalt, 154 | newRecoveryOwners, 155 | address(safe), 156 | recoveryFuzz.threshold, 157 | recoveryFuzz.recoveryThreshold, 158 | recoveryDelay 159 | ); 160 | 161 | assertEq( 162 | recoveryAddress, 163 | address(recovery), 164 | "recovery spell address incorrect" 165 | ); 166 | assertTrue( 167 | address(recovery).code.length != 0, "recovery spell not created" 168 | ); 169 | 170 | assertEq( 171 | recovery.recoveryInitiated(), 172 | block.timestamp, 173 | "recovery not initiated" 174 | ); 175 | 176 | vm.warp(block.timestamp + recoveryDelay + 1); 177 | 178 | { 179 | bytes32[] memory r = new bytes32[](recoveryKeys.length); 180 | bytes32[] memory s = new bytes32[](recoveryKeys.length); 181 | uint8[] memory v = new uint8[](recoveryKeys.length); 182 | 183 | bytes32 digest = recovery.getDigest(); 184 | 185 | for (uint256 i = 0; i < recoveryKeys.length; i++) { 186 | (v[i], r[i], s[i]) = vm.sign(recoveryKeys[i], digest); 187 | } 188 | 189 | recovery.executeRecovery(address(1), v, r, s); 190 | } 191 | 192 | assertFalse( 193 | safe.isModuleEnabled(recoveryAddress), 194 | "recovery spell should be removed after execution" 195 | ); 196 | 197 | for (uint256 i = 0; i < owners.length; i++) { 198 | assertFalse( 199 | safe.isOwner(owners[i]), 200 | "owner should be removed after recovery" 201 | ); 202 | } 203 | 204 | for (uint256 i = 0; i < newRecoveryOwners.length; i++) { 205 | assertTrue( 206 | safe.isOwner(newRecoveryOwners[i]), 207 | "recovery owner should be an owner" 208 | ); 209 | } 210 | 211 | assertEq( 212 | safe.getThreshold(), recoveryFuzz.threshold, "threshold incorrect" 213 | ); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /test/mock/MockERC1155.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.20; 2 | 3 | import {ERC1155} from 4 | "@openzeppelin-contracts/contracts/token/ERC1155/ERC1155.sol"; 5 | 6 | contract MockERC1155 is ERC1155("URI") { 7 | function mint(address to, uint256 tokenId, uint256 amount) public { 8 | _mint(to, tokenId, amount, ""); 9 | } 10 | 11 | function mintBatch( 12 | address to, 13 | uint256[] memory ids, 14 | uint256[] memory values 15 | ) public { 16 | _mintBatch(to, ids, values, ""); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/mock/MockERC721.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {ERC721} from "@openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; 4 | 5 | contract MockERC721 is ERC721("Mock", "MOCK NFT") { 6 | function mint(address to, uint256 tokenId) public { 7 | _mint(to, tokenId); 8 | } 9 | 10 | function safeMint(address to, uint256 tokenId) public { 11 | _safeMint(to, tokenId); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/mock/MockLending.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | contract MockLending { 4 | mapping(address owner => uint256 amount) _balance; 5 | 6 | function deposit(address to, uint256 amount) external { 7 | _balance[to] += amount; 8 | 9 | /// token.transferFrom(msg.sender, address(this), amount); 10 | } 11 | 12 | function withdraw(address to, uint256 amount) external { 13 | to; 14 | /// shhhhh 15 | 16 | _balance[msg.sender] -= amount; 17 | 18 | /// token.transfer(to, amount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/mock/MockReentrancyExecutor.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {Timelock} from "src/Timelock.sol"; 4 | 5 | contract MockReentrancyExecutor { 6 | bool public executeBatch; 7 | 8 | function setExecuteBatch(bool _executeBatch) external { 9 | executeBatch = _executeBatch; 10 | } 11 | 12 | receive() external payable { 13 | if (!executeBatch) { 14 | Timelock(payable(msg.sender)).execute( 15 | address(this), 0, "", bytes32(0) 16 | ); 17 | } else { 18 | address[] memory targets = new address[](1); 19 | targets[0] = address(this); 20 | 21 | uint256[] memory values = new uint256[](1); 22 | values[0] = 0; 23 | 24 | bytes[] memory datas = new bytes[](1); 25 | datas[0] = ""; 26 | 27 | Timelock(payable(msg.sender)).executeBatch( 28 | targets, values, datas, bytes32(0) 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/mock/MockSafe.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {Enum} from "@safe/common/Enum.sol"; 4 | 5 | contract MockSafe { 6 | address[] public owners; 7 | 8 | bool public execTransactionModuleSuccess; 9 | 10 | function setExecTransactionModuleSuccess(bool _success) public { 11 | execTransactionModuleSuccess = _success; 12 | } 13 | 14 | function setOwners(address[] memory _owners) public { 15 | owners = _owners; 16 | } 17 | 18 | function isOwner(address user) public view returns (bool) { 19 | for (uint256 i = 0; i < owners.length; i++) { 20 | if (owners[i] == user) { 21 | return true; 22 | } 23 | } 24 | 25 | return false; 26 | } 27 | 28 | function getOwners() public view returns (address[] memory) { 29 | return owners; 30 | } 31 | 32 | /// used to execute arbitrary code, mainly queuing actions in the timelock 33 | function arbitraryExecution(address target, bytes memory data) public { 34 | (bool success, bytes memory returnData) = target.call{value: 0}(data); 35 | require(success, string(returnData)); 36 | } 37 | 38 | /// no-op, used to unit test recovery spell 39 | function execTransactionFromModule( 40 | address, 41 | uint256, 42 | bytes memory, 43 | Enum.Operation 44 | ) public virtual returns (bool success) { 45 | success = execTransactionModuleSuccess; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/mock/MockTwoParams.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | contract MockTwoParams { 4 | mapping(address owner => mapping(address token => uint256 amount)) _balance; 5 | 6 | function deposit(address token, address to, uint256 amount) external { 7 | _balance[token][to] += amount; 8 | 9 | /// token.transferFrom(msg.sender, address(this), amount); 10 | } 11 | 12 | function withdraw(address token, address to, uint256 amount) external { 13 | to; 14 | /// shhhhh 15 | 16 | _balance[token][msg.sender] -= amount; 17 | 18 | /// token.transfer(to, amount); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/BytesHelper.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {BytesHelper} from "@src/BytesHelper.sol"; 4 | 5 | import {Test} from "forge-std/Test.sol"; 6 | 7 | contract BytesHelperUnitTest is Test { 8 | using BytesHelper for bytes; 9 | 10 | function testGetFunctionSignatureFailsLt4Bytes() public { 11 | bytes memory toSlice = new bytes(0); 12 | vm.expectRevert("No function signature"); 13 | toSlice.getFunctionSignature(); 14 | 15 | toSlice = new bytes(1); 16 | vm.expectRevert("No function signature"); 17 | toSlice.getFunctionSignature(); 18 | 19 | toSlice = new bytes(2); 20 | vm.expectRevert("No function signature"); 21 | toSlice.getFunctionSignature(); 22 | 23 | toSlice = new bytes(3); 24 | vm.expectRevert("No function signature"); 25 | toSlice.getFunctionSignature(); 26 | } 27 | 28 | function testGetFirstWordFailsLt32Bytes() public { 29 | bytes memory toSlice = new bytes(0); 30 | vm.expectRevert("Length less than 32 bytes"); 31 | toSlice.getFirstWord(); 32 | 33 | toSlice = new bytes(31); 34 | vm.expectRevert("Length less than 32 bytes"); 35 | toSlice.getFirstWord(); 36 | } 37 | 38 | function testSliceBytesFailsStartGtLength() public { 39 | bytes memory toSlice = new bytes(10); 40 | vm.expectRevert( 41 | "Start index is greater than the length of the byte string" 42 | ); 43 | toSlice.sliceBytes(11, 0); 44 | } 45 | 46 | function testSliceBytesFailsEndGtLength() public { 47 | bytes memory toSlice = new bytes(10); 48 | vm.expectRevert( 49 | "End index is greater than the length of the byte string" 50 | ); 51 | toSlice.sliceBytes(0, 11); 52 | } 53 | 54 | function testSliceBytesSucceedsEqEndLength() public pure { 55 | bytes memory toSlice = new bytes(10); 56 | toSlice.sliceBytes(0, 9); 57 | } 58 | 59 | function testSliceBytesFailsStartGtEnd() public { 60 | bytes memory toSlice = new bytes(10); 61 | vm.expectRevert("Start index not less than end index"); 62 | toSlice.sliceBytes(6, 5); 63 | } 64 | 65 | function testSliceBytesFailsStartEqEnd() public { 66 | bytes memory toSlice = new bytes(10); 67 | vm.expectRevert("Start index not less than end index"); 68 | toSlice.sliceBytes(6, 6); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/unit/Guard.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.13; 2 | 3 | import {Enum} from "@safe/common/Enum.sol"; 4 | 5 | import {console} from "forge-std/Test.sol"; 6 | 7 | import {Guard} from "src/Guard.sol"; 8 | import {CallHelper} from "test/utils/CallHelper.t.sol"; 9 | 10 | contract GuardUnitTest is CallHelper { 11 | Guard public guard; 12 | 13 | address public timelock; 14 | 15 | address[] public owners; 16 | 17 | /// @notice storage slot for the guard 18 | uint256 internal constant GUARD_STORAGE_SLOT = 19 | 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; 20 | 21 | /// @notice storage slot for the fallback handler 22 | /// keccak256("fallback_manager.handler.address") 23 | uint256 private constant FALLBACK_HANDLER_STORAGE_SLOT = 24 | 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5; 25 | 26 | function setUp() public { 27 | guard = new Guard(); 28 | vm.etch(timelock, hex"FF"); 29 | owners = new address[](0); 30 | } 31 | 32 | function testCheckTransaction() public view { 33 | guard.checkTransaction( 34 | address(0), 35 | 0, 36 | "", 37 | Enum.Operation.Call, 38 | 0, 39 | 0, 40 | 0, 41 | address(0), 42 | payable(address(9)), 43 | "", 44 | address(0) 45 | ); 46 | 47 | /// transaction is fine within the allowed time range 48 | guard.checkTransaction( 49 | address(0), 50 | 0, 51 | "", 52 | Enum.Operation.Call, 53 | 0, 54 | 0, 55 | 0, 56 | address(0), 57 | payable(address(9)), 58 | "", 59 | address(0) 60 | ); 61 | } 62 | 63 | function testTransactionDelegateCallFails() public { 64 | vm.expectRevert("Guard: delegate call disallowed"); 65 | guard.checkTransaction( 66 | address(this), 67 | 0, 68 | "", 69 | Enum.Operation.DelegateCall, 70 | 0, 71 | 0, 72 | 0, 73 | address(0), 74 | payable(address(9)), 75 | "", 76 | address(0) 77 | ); 78 | } 79 | 80 | function testTransactionToSelfFailsValue() public { 81 | vm.expectRevert("Guard: no self calls"); 82 | guard.checkTransaction( 83 | address(this), 84 | 1, 85 | "", 86 | Enum.Operation.DelegateCall, 87 | 0, 88 | 0, 89 | 0, 90 | address(0), 91 | payable(address(9)), 92 | "", 93 | address(0) 94 | ); 95 | } 96 | 97 | function testTransactionToSelfFailsData() public { 98 | vm.expectRevert("Guard: no self calls"); 99 | guard.checkTransaction( 100 | address(this), 101 | 0, 102 | hex"FF", 103 | Enum.Operation.DelegateCall, 104 | 0, 105 | 0, 106 | 0, 107 | address(0), 108 | payable(address(9)), 109 | "", 110 | address(0) 111 | ); 112 | } 113 | 114 | function testCheckAfterExecutionNoOp() public view { 115 | guard.checkAfterExecution(bytes32(0), false); 116 | } 117 | 118 | function getStorageAt(uint256 offset, uint256 length) 119 | public 120 | view 121 | returns (bytes memory) 122 | { 123 | bytes memory result = new bytes(length * 32); 124 | for (uint256 index = 0; index < length; index++) { 125 | // solhint-disable-next-line no-inline-assembly 126 | assembly ("memory-safe") { 127 | let word := sload(add(offset, index)) 128 | mstore(add(add(result, 0x20), mul(index, 0x20)), word) 129 | } 130 | } 131 | return result; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/unit/RecoverySpell.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {ECDSA} from 4 | "@openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; 5 | 6 | import {Test, stdError} from "forge-std/Test.sol"; 7 | 8 | import {MockSafe} from "test/mock/MockSafe.sol"; 9 | import {RecoverySpell} from "@src/RecoverySpell.sol"; 10 | 11 | contract RecoverySpellUnitTest is Test { 12 | uint256 public recoveryDelay = 1 days; 13 | MockSafe safe; 14 | 15 | uint256[] public recoveryPrivateKeys; 16 | 17 | address[] public recoveryOwners; 18 | 19 | /// @notice event emitted when the recovery is executed 20 | event SafeRecovered(uint256 indexed time); 21 | 22 | function setUp() public { 23 | vm.warp(1000); 24 | 25 | recoveryPrivateKeys.push(10); 26 | recoveryPrivateKeys.push(20); 27 | recoveryPrivateKeys.push(30); 28 | recoveryPrivateKeys.push(40); 29 | recoveryPrivateKeys.push(50); 30 | 31 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 32 | recoveryOwners.push(vm.addr(recoveryPrivateKeys[i])); 33 | } 34 | 35 | safe = new MockSafe(); 36 | safe.setOwners(recoveryOwners); 37 | } 38 | 39 | function testDomainSeparatorDifferentTwoContracts() public { 40 | RecoverySpell recovery1 = new RecoverySpell( 41 | recoveryOwners, address(safe), 2, 4, recoveryDelay 42 | ); 43 | 44 | RecoverySpell recovery2 = new RecoverySpell( 45 | recoveryOwners, address(safe), 2, 4, recoveryDelay 46 | ); 47 | 48 | assertNotEq( 49 | recovery1.getDigest(), 50 | recovery2.getDigest(), 51 | "Domain separator should be different" 52 | ); 53 | } 54 | 55 | /// recovery tests 56 | 57 | function testInitiateRecoverySucceeds() 58 | public 59 | returns (RecoverySpell recovery) 60 | { 61 | address[] memory owners = new address[](4); 62 | owners[0] = address(0x1); 63 | owners[1] = address(0x2); 64 | owners[2] = address(0x3); 65 | owners[3] = address(0x4); 66 | 67 | recovery = new RecoverySpell(owners, address(safe), 2, 0, recoveryDelay); 68 | 69 | vm.prank(owners[0]); 70 | 71 | assertEq( 72 | recovery.recoveryInitiated(), 73 | block.timestamp, 74 | "Recovery initiated time not stored" 75 | ); 76 | } 77 | 78 | function testExecuteRecoveryFailsNotInitiated() public { 79 | RecoverySpell recovery = 80 | new RecoverySpell(new address[](1), address(safe), 0, 0, 1); 81 | 82 | vm.expectRevert("RecoverySpell: Recovery not ready"); 83 | recovery.executeRecovery( 84 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 85 | ); 86 | } 87 | 88 | function testExecuteRecoveryFailsNotPassedDelay() public { 89 | RecoverySpell recovery = testInitiateRecoverySucceeds(); 90 | 91 | vm.expectRevert("RecoverySpell: Recovery not ready"); 92 | recovery.executeRecovery( 93 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 94 | ); 95 | } 96 | 97 | function testRecoveryFailsNotPassedDelay() public { 98 | RecoverySpell recovery = new RecoverySpell( 99 | recoveryOwners, address(safe), 2, 4, recoveryDelay 100 | ); 101 | 102 | vm.expectRevert("RecoverySpell: Recovery not ready"); 103 | recovery.executeRecovery( 104 | address(1), new uint8[](1), new bytes32[](1), new bytes32[](1) 105 | ); 106 | } 107 | 108 | function testRecoverySucceeds() public returns (RecoverySpell recovery) { 109 | recovery = testInitiateRecoverySucceeds(); 110 | 111 | vm.warp(block.timestamp + recoveryDelay + 1); 112 | 113 | safe.setExecTransactionModuleSuccess(true); 114 | 115 | vm.expectEmit(true, true, true, true, address(recovery)); 116 | emit SafeRecovered(block.timestamp); 117 | 118 | recovery.executeRecovery( 119 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 120 | ); 121 | 122 | assertEq(recovery.getOwners().length, 0, "Owners not removed"); 123 | assertEq( 124 | recovery.recoveryInitiated(), 125 | type(uint256).max, 126 | "Recovery not reset" 127 | ); 128 | } 129 | 130 | function testRecoverySucceedsMultipleSignatures() public { 131 | RecoverySpell recovery = new RecoverySpell( 132 | recoveryOwners, address(safe), 2, 4, recoveryDelay 133 | ); 134 | 135 | assertEq( 136 | recovery.recoveryInitiated(), 137 | block.timestamp, 138 | "Recovery initiated time not stored" 139 | ); 140 | 141 | vm.warp(block.timestamp + recoveryDelay + 1); 142 | 143 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 144 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 145 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 146 | 147 | bytes32 digest = recovery.getDigest(); 148 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 149 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 150 | } 151 | 152 | safe.setExecTransactionModuleSuccess(true); 153 | 154 | vm.expectEmit(true, true, true, true, address(recovery)); 155 | emit SafeRecovered(block.timestamp); 156 | 157 | recovery.executeRecovery(address(1), v, r, s); 158 | 159 | assertEq(recovery.getOwners().length, 0, "Owners not removed"); 160 | assertEq( 161 | recovery.recoveryInitiated(), 162 | type(uint256).max, 163 | "Recovery not reset" 164 | ); 165 | } 166 | 167 | function testRecoveryFailsDuplicateSignature() public { 168 | RecoverySpell recovery = new RecoverySpell( 169 | recoveryOwners, address(safe), 2, 4, recoveryDelay 170 | ); 171 | 172 | assertEq( 173 | recovery.recoveryInitiated(), 174 | block.timestamp, 175 | "Recovery initiated time not stored" 176 | ); 177 | 178 | vm.warp(block.timestamp + recoveryDelay + 1); 179 | 180 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 181 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 182 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 183 | 184 | bytes32 digest = recovery.getDigest(); 185 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 186 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 187 | } 188 | 189 | /// final signature duplicate 190 | v[v.length - 1] = v[v.length - 2]; 191 | r[r.length - 1] = r[r.length - 2]; 192 | s[s.length - 1] = s[s.length - 2]; 193 | 194 | vm.expectRevert("RecoverySpell: Invalid signature"); 195 | recovery.executeRecovery(address(1), v, r, s); 196 | } 197 | 198 | function testRecoveryFailsInvalidSignature() public { 199 | RecoverySpell recovery = new RecoverySpell( 200 | recoveryOwners, address(safe), 2, 4, recoveryDelay 201 | ); 202 | 203 | assertEq( 204 | recovery.recoveryInitiated(), 205 | block.timestamp, 206 | "Recovery initiated time not stored" 207 | ); 208 | 209 | vm.warp(block.timestamp + recoveryDelay + 1); 210 | 211 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 212 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 213 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 214 | 215 | bytes32 digest = recovery.getDigest(); 216 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 217 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 218 | } 219 | /// invalid signature 220 | v[v.length - 1] = v[v.length - 1] + 1; 221 | 222 | vm.expectRevert(ECDSA.ECDSAInvalidSignature.selector); 223 | recovery.executeRecovery(address(1), v, r, s); 224 | 225 | v[v.length - 1] = v[v.length - 1] - 1; 226 | bytes32 rval = r[0]; 227 | r[0] = bytes32(uint256(21)); 228 | 229 | vm.expectRevert(ECDSA.ECDSAInvalidSignature.selector); 230 | recovery.executeRecovery(address(1), v, r, s); 231 | 232 | v[v.length - 1] = v[v.length - 1] - 1; 233 | r[r.length - 1] = bytes32(uint256(21)); 234 | r[0] = rval; 235 | 236 | vm.expectRevert(ECDSA.ECDSAInvalidSignature.selector); 237 | recovery.executeRecovery(address(1), v, r, s); 238 | } 239 | 240 | function testExecuteRecoveryFailsPostRecoverySuccess() public { 241 | RecoverySpell recovery = testRecoverySucceeds(); 242 | 243 | vm.expectRevert("RecoverySpell: Already recovered"); 244 | recovery.executeRecovery( 245 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 246 | ); 247 | } 248 | 249 | function testRecoveryNoSignaturesFailsMulticall() public { 250 | RecoverySpell recovery = testInitiateRecoverySucceeds(); 251 | 252 | vm.warp(block.timestamp + recoveryDelay + 1); 253 | 254 | safe.setExecTransactionModuleSuccess(false); 255 | 256 | vm.expectRevert("RecoverySpell: Recovery failed"); 257 | recovery.executeRecovery( 258 | address(1), new uint8[](0), new bytes32[](0), new bytes32[](0) 259 | ); 260 | } 261 | 262 | function testRecoveryWithSignaturesFailsMulticall() public { 263 | RecoverySpell recovery = new RecoverySpell( 264 | recoveryOwners, address(safe), 2, 4, recoveryDelay 265 | ); 266 | 267 | assertEq( 268 | recovery.recoveryInitiated(), 269 | block.timestamp, 270 | "Recovery initiated time not stored" 271 | ); 272 | 273 | vm.warp(block.timestamp + recoveryDelay + 1); 274 | 275 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 276 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 277 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 278 | 279 | bytes32 digest = recovery.getDigest(); 280 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 281 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 282 | } 283 | 284 | safe.setExecTransactionModuleSuccess(false); 285 | 286 | vm.expectRevert("RecoverySpell: Recovery failed"); 287 | recovery.executeRecovery(address(1), v, r, s); 288 | } 289 | 290 | function testRecoveryNotEnoughSignaturesFails() public { 291 | RecoverySpell recovery = new RecoverySpell( 292 | recoveryOwners, address(safe), 2, 5, recoveryDelay 293 | ); 294 | 295 | assertEq( 296 | recovery.recoveryInitiated(), 297 | block.timestamp, 298 | "Recovery initiated time not stored" 299 | ); 300 | 301 | vm.warp(block.timestamp + recoveryDelay + 1); 302 | 303 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length - 1); 304 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length - 1); 305 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length - 1); 306 | 307 | bytes32 digest = recovery.getDigest(); 308 | for (uint256 i = 0; i < recoveryPrivateKeys.length - 1; i++) { 309 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 310 | } 311 | 312 | vm.expectRevert("RecoverySpell: Not enough signatures"); 313 | recovery.executeRecovery(address(1), v, r, s); 314 | } 315 | 316 | function testRecoveryFailsSignatureLengthMismatch() public { 317 | RecoverySpell recovery = new RecoverySpell( 318 | recoveryOwners, address(safe), 2, 5, recoveryDelay 319 | ); 320 | 321 | assertEq( 322 | recovery.recoveryInitiated(), 323 | block.timestamp, 324 | "Recovery initiated time not stored" 325 | ); 326 | 327 | vm.warp(block.timestamp + recoveryDelay + 1); 328 | 329 | { 330 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length - 1); 331 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length - 1); 332 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length); 333 | 334 | bytes32 digest = recovery.getDigest(); 335 | for (uint256 i = 0; i < recoveryPrivateKeys.length - 1; i++) { 336 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 337 | } 338 | 339 | vm.expectRevert("RecoverySpell: Invalid signature parameters"); 340 | recovery.executeRecovery(address(1), v, r, s); 341 | } 342 | 343 | { 344 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length - 1); 345 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length); 346 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length - 1); 347 | 348 | bytes32 digest = recovery.getDigest(); 349 | for (uint256 i = 0; i < recoveryPrivateKeys.length - 1; i++) { 350 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 351 | } 352 | 353 | vm.expectRevert("RecoverySpell: Invalid signature parameters"); 354 | recovery.executeRecovery(address(1), v, r, s); 355 | } 356 | { 357 | bytes32[] memory r = new bytes32[](recoveryPrivateKeys.length); 358 | bytes32[] memory s = new bytes32[](recoveryPrivateKeys.length - 1); 359 | uint8[] memory v = new uint8[](recoveryPrivateKeys.length - 1); 360 | 361 | bytes32 digest = recovery.getDigest(); 362 | for (uint256 i = 0; i < recoveryPrivateKeys.length - 1; i++) { 363 | (v[i], r[i], s[i]) = vm.sign(recoveryPrivateKeys[i], digest); 364 | } 365 | 366 | vm.expectRevert("RecoverySpell: Invalid signature parameters"); 367 | recovery.executeRecovery(address(1), v, r, s); 368 | } 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /test/unit/TimelockFactory.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import "test/utils/TimelockUnitFixture.sol"; 4 | 5 | contract TimelockFactoryUnitTest is TimelockUnitFixture { 6 | /// @notice Emitted when a call is scheduled as part of operation `id`. 7 | /// @param timelock address of the newly created timelock 8 | /// @param creationTime of the new timelock 9 | /// @param sender that called the contract to create the timelock 10 | event TimelockCreated( 11 | address indexed timelock, uint256 creationTime, address indexed sender 12 | ); 13 | 14 | function testTimelockCreation() public view { 15 | assertEq(timelock.minDelay(), MIN_DELAY, "Min delay should be set"); 16 | assertEq( 17 | timelock.expirationPeriod(), 18 | EXPIRATION_PERIOD, 19 | "Expiration period should be set" 20 | ); 21 | assertEq(timelock.pauseGuardian(), guardian, "Guardian should be set"); 22 | assertEq( 23 | timelock.pauseDuration(), 24 | PAUSE_DURATION, 25 | "Pause duration should be set" 26 | ); 27 | 28 | assertFalse( 29 | timelock.pauseStartTime() != 0, "timelock pause should not be used" 30 | ); 31 | assertFalse(timelock.paused(), "timelock should not be paused"); 32 | 33 | assertTrue( 34 | timelock.hasRole(timelock.HOT_SIGNER_ROLE(), HOT_SIGNER_ONE), 35 | "Hot signer one should have role" 36 | ); 37 | assertTrue( 38 | timelock.hasRole(timelock.HOT_SIGNER_ROLE(), HOT_SIGNER_TWO), 39 | "Hot signer two should have role" 40 | ); 41 | assertTrue( 42 | timelock.hasRole(timelock.HOT_SIGNER_ROLE(), HOT_SIGNER_THREE), 43 | "Hot signer three should have role" 44 | ); 45 | } 46 | 47 | function testCreateTimelockThroughFactory() public { 48 | DeploymentParams memory params = DeploymentParams({ 49 | minDelay: MINIMUM_DELAY + 1, 50 | expirationPeriod: EXPIRATION_PERIOD, 51 | pauser: guardian, 52 | pauseDuration: PAUSE_DURATION, 53 | hotSigners: hotSigners, 54 | contractAddresses: new address[](0), 55 | selectors: new bytes4[](0), 56 | startIndexes: new uint16[](0), 57 | endIndexes: new uint16[](0), 58 | datas: new bytes[][](0), 59 | salt: salt 60 | }); 61 | 62 | address newTimelock = 63 | timelockFactory.createTimelock(address(this), params); 64 | 65 | assertTrue(newTimelock.code.length > 0, "Timelock not created"); 66 | } 67 | 68 | function testCreateTimelockThroughFactoryDifferentSendersSameParams() 69 | public 70 | { 71 | DeploymentParams memory params = DeploymentParams({ 72 | minDelay: MINIMUM_DELAY + 1, 73 | expirationPeriod: EXPIRATION_PERIOD, 74 | pauser: guardian, 75 | pauseDuration: PAUSE_DURATION, 76 | hotSigners: hotSigners, 77 | contractAddresses: new address[](0), 78 | selectors: new bytes4[](0), 79 | startIndexes: new uint16[](0), 80 | endIndexes: new uint16[](0), 81 | datas: new bytes[][](0), 82 | salt: salt 83 | }); 84 | 85 | vm.prank(address(1000000000)); 86 | address newTimelockSenderOne = 87 | timelockFactory.createTimelock(address(this), params); 88 | 89 | assertTrue(newTimelockSenderOne.code.length > 0, "Timelock not created"); 90 | 91 | vm.prank(address(2000000000)); 92 | address newTimelockSenderTwo = 93 | timelockFactory.createTimelock(address(this), params); 94 | 95 | assertTrue(newTimelockSenderTwo.code.length > 0, "Timelock not created"); 96 | 97 | assertNotEq( 98 | newTimelockSenderTwo, 99 | newTimelockSenderOne, 100 | "Timelocks should be different" 101 | ); 102 | } 103 | 104 | function testTimelockCreationCode() public view { 105 | bytes memory timelockCreationCode = 106 | timelockFactory.timelockCreationCode(); 107 | bytes memory actualTimelockCreationCode = type(Timelock).creationCode; 108 | 109 | assertEq( 110 | timelockCreationCode, 111 | actualTimelockCreationCode, 112 | "Timelock creation code should be empty" 113 | ); 114 | } 115 | 116 | function testTimelockCreatedEventEmitted() public { 117 | address safeAddress = address(0x3afe); 118 | DeploymentParams memory params = DeploymentParams({ 119 | minDelay: MINIMUM_DELAY + 1, 120 | expirationPeriod: EXPIRATION_PERIOD, 121 | pauser: guardian, 122 | pauseDuration: PAUSE_DURATION, 123 | hotSigners: hotSigners, 124 | contractAddresses: new address[](0), 125 | selectors: new bytes4[](0), 126 | startIndexes: new uint16[](0), 127 | endIndexes: new uint16[](0), 128 | datas: new bytes[][](0), 129 | salt: salt 130 | }); 131 | 132 | Create2Params memory create2Params; 133 | create2Params.creator = address(timelockFactory); 134 | create2Params.creationCode = timelockFactory.timelockCreationCode(); 135 | create2Params.constructorParams = abi.encode( 136 | safeAddress, 137 | params.minDelay, 138 | params.expirationPeriod, 139 | params.pauser, 140 | params.pauseDuration, 141 | params.hotSigners 142 | ); 143 | create2Params.salt = 144 | keccak256(abi.encodePacked(params.salt, address(this))); 145 | 146 | address newTimelock = calculateCreate2Address(create2Params); 147 | 148 | vm.expectEmit(true, true, true, true, address(timelockFactory)); 149 | emit TimelockCreated(newTimelock, block.timestamp, address(this)); 150 | 151 | timelockFactory.createTimelock(safeAddress, params); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /test/unit/TimelockPause.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import "test/utils/TimelockUnitFixture.sol"; 4 | 5 | contract TimelockPauseUnitTest is TimelockUnitFixture { 6 | function testSetup() public view { 7 | assertEq(timelock.pauseGuardian(), guardian, "guardian incorrectly set"); 8 | assertEq( 9 | timelock.pauseDuration(), 10 | PAUSE_DURATION, 11 | "pause duration incorrectly set" 12 | ); 13 | assertFalse( 14 | timelock.pauseStartTime() != 0, "pause should not be used yet" 15 | ); 16 | assertEq(timelock.pauseStartTime(), 0, "pauseStartTime should be 0"); 17 | } 18 | 19 | /// Pause Tests 20 | /// - test that functions revert when paused: 21 | /// - schedule 22 | /// - scheduleBatch 23 | /// - execute 24 | /// - executeBatch 25 | 26 | function testScheduleFailsWhenPaused() public { 27 | vm.prank(guardian); 28 | timelock.pause(); 29 | 30 | vm.expectRevert("Pausable: paused"); 31 | vm.prank(address(safe)); 32 | timelock.schedule( 33 | address(timelock), 34 | 0, 35 | abi.encodeWithSelector(timelock.updateDelay.selector, MINIMUM_DELAY), 36 | bytes32(0), 37 | MINIMUM_DELAY 38 | ); 39 | } 40 | 41 | function testScheduleBatchFailsWhenPaused() public { 42 | vm.prank(guardian); 43 | timelock.pause(); 44 | 45 | vm.expectRevert("Pausable: paused"); 46 | vm.prank(address(safe)); 47 | timelock.scheduleBatch( 48 | new address[](0), 49 | new uint256[](0), 50 | new bytes[](0), 51 | bytes32(0), 52 | MINIMUM_DELAY 53 | ); 54 | } 55 | 56 | function testExecuteFailsWhenPaused() public { 57 | vm.prank(guardian); 58 | timelock.pause(); 59 | 60 | vm.expectRevert("Pausable: paused"); 61 | timelock.execute( 62 | address(timelock), 63 | 0, 64 | abi.encodeWithSelector(timelock.updateDelay.selector, MINIMUM_DELAY), 65 | bytes32(0) 66 | ); 67 | } 68 | 69 | function testExecuteBatchFailsWhenPaused() public { 70 | vm.prank(guardian); 71 | timelock.pause(); 72 | 73 | vm.expectRevert("Pausable: paused"); 74 | timelock.executeBatch( 75 | new address[](0), new uint256[](0), new bytes[](0), bytes32(0) 76 | ); 77 | } 78 | 79 | function testSetGuardianSucceedsAsTimelockAndUnpauses() public { 80 | address newGuardian = address(0x22222); 81 | vm.prank(guardian); 82 | timelock.pause(); 83 | 84 | assertTrue(timelock.paused(), "not paused"); 85 | assertTrue(timelock.pauseStartTime() != 0, "pause should not be used"); 86 | assertEq( 87 | timelock.pauseStartTime(), 88 | block.timestamp, 89 | "pauseStartTime should be 0" 90 | ); 91 | 92 | vm.prank(address(timelock)); 93 | timelock.setGuardian(newGuardian); 94 | 95 | assertEq( 96 | timelock.pauseGuardian(), 97 | newGuardian, 98 | "new guardian not correctly set" 99 | ); 100 | assertEq(timelock.pauseStartTime(), 0, "pauseStartTime should be 0"); 101 | assertFalse(timelock.paused(), "timelock should not be paused"); 102 | assertFalse(timelock.pauseStartTime() != 0, "pause should not be used"); 103 | } 104 | 105 | function testGuardianPauseAfterUnpauseFails() public { 106 | vm.prank(guardian); 107 | timelock.pause(); 108 | 109 | timelock.pauseGuardian(); 110 | 111 | assertTrue(timelock.paused(), "not paused"); 112 | assertTrue(timelock.pauseStartTime() != 0, "pause should not be used"); 113 | assertEq( 114 | timelock.pauseStartTime(), 115 | block.timestamp, 116 | "pauseStartTime should be 0" 117 | ); 118 | 119 | vm.warp(block.timestamp + PAUSE_DURATION); 120 | 121 | assertTrue(timelock.paused(), "timelock should be paused"); 122 | 123 | vm.expectRevert("Pausable: paused"); 124 | vm.prank(guardian); 125 | timelock.pause(); 126 | 127 | vm.warp(block.timestamp + 1); 128 | assertFalse(timelock.paused(), "timelock should not be paused"); 129 | 130 | timelock.pauseGuardian(); 131 | 132 | vm.expectRevert("ConfigurablePauseGuardian: only pause guardian"); 133 | vm.prank(guardian); 134 | timelock.pause(); 135 | } 136 | 137 | function testUpdatePauseDurationTimelockSucceeds(uint128 newDuration) 138 | public 139 | { 140 | newDuration = uint128( 141 | _bound( 142 | newDuration, 143 | timelock.MIN_PAUSE_DURATION(), 144 | timelock.MAX_PAUSE_DURATION() 145 | ) 146 | ); 147 | 148 | vm.prank(address(timelock)); 149 | timelock.updatePauseDuration(newDuration); 150 | 151 | assertEq( 152 | timelock.pauseDuration(), newDuration, "pause duration not updated" 153 | ); 154 | } 155 | 156 | function testUpdatePauseDurationLessThanMinFails() public { 157 | uint128 newDuration = uint128(timelock.MIN_PAUSE_DURATION()) - 1; 158 | 159 | vm.expectRevert("ConfigurablePause: pause duration out of bounds"); 160 | vm.prank(address(timelock)); 161 | timelock.updatePauseDuration(newDuration); 162 | } 163 | 164 | function testUpdatePauseDurationGtMaxFails() public { 165 | uint128 newDuration = uint128(timelock.MAX_PAUSE_DURATION()) + 1; 166 | 167 | vm.expectRevert("ConfigurablePause: pause duration out of bounds"); 168 | vm.prank(address(timelock)); 169 | timelock.updatePauseDuration(newDuration); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /test/unit/TimelockReceiving.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {IERC1155Receiver} from 4 | "@openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sol"; 5 | import {IERC721Receiver} from 6 | "@openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol"; 7 | import { 8 | IERC165, 9 | ERC165 10 | } from "@openzeppelin-contracts/contracts/utils/introspection/ERC165.sol"; 11 | 12 | import {Test, console} from "forge-std/Test.sol"; 13 | 14 | import {Timelock} from "src/Timelock.sol"; 15 | import {MockSafe} from "test/mock/MockSafe.sol"; 16 | import {MockERC721} from "test/mock/MockERC721.sol"; 17 | import {MockERC1155} from "test/mock/MockERC1155.sol"; 18 | import {MIN_DELAY as MINIMUM_DELAY} from "src/utils/Constants.sol"; 19 | 20 | contract TimelockReceivingUnitTest is Test { 21 | /// @notice reference to the Timelock contract 22 | Timelock private timelock; 23 | 24 | /// @notice reference to the MockSafe contract 25 | MockSafe private safe; 26 | 27 | /// @notice reference to the MockERC1155 contract 28 | MockERC1155 private erc1155; 29 | 30 | /// @notice reference to the MockERC721 contract 31 | MockERC721 private erc721; 32 | 33 | /// @notice address of the guardian that can pause in case of emergency 34 | address public guardian = address(0x11111); 35 | 36 | /// @notice duration of pause 37 | uint128 public constant PAUSE_DURATION = 10 days; 38 | 39 | /// @notice expiration period for a timelocked transaction in seconds 40 | uint256 public constant EXPIRATION_PERIOD = 5 days; 41 | 42 | function setUp() public { 43 | // at least start at unix timestamp of 1m so that block timestamp isn't 0 44 | vm.warp(block.timestamp + 1_000_000); 45 | 46 | safe = new MockSafe(); 47 | 48 | erc1155 = new MockERC1155(); 49 | erc721 = new MockERC721(); 50 | 51 | // Assume the necessary parameters for the constructor 52 | timelock = new Timelock( 53 | address(safe), // _safe 54 | MINIMUM_DELAY, // _minDelay 55 | EXPIRATION_PERIOD, // _expirationPeriod 56 | guardian, // _pauser 57 | PAUSE_DURATION, // _pauseDuration 58 | new address[](0) // hotSigners 59 | ); 60 | 61 | timelock.initialize( 62 | new address[](0), // targets 63 | new bytes4[](0), // selectors 64 | new uint16[](0), // startIndexes 65 | new uint16[](0), // endIndexes 66 | new bytes[][](0) // datas 67 | ); 68 | } 69 | 70 | function testReceive1155Mint() public { 71 | erc1155.mint(address(timelock), 2, 1); 72 | 73 | assertEq( 74 | erc1155.balanceOf(address(timelock), 2), 75 | 1, 76 | "id does not have correct balance" 77 | ); 78 | } 79 | 80 | function testReceive1155BatchMint() public { 81 | uint256[] memory ids = new uint256[](4); 82 | uint256[] memory values = new uint256[](4); 83 | ids[0] = 0; 84 | ids[1] = 1; 85 | ids[2] = 2; 86 | ids[3] = 3; 87 | 88 | values[0] = 100_000; 89 | values[1] = 100_000_000; 90 | values[2] = 100_000_000_000; 91 | values[3] = 100_000_000_000_000; 92 | 93 | erc1155.mintBatch(address(timelock), ids, values); 94 | 95 | for (uint256 i = 0; i < ids.length; i++) { 96 | assertEq( 97 | erc1155.balanceOf(address(timelock), ids[i]), 98 | values[i], 99 | "id does not have correct balance" 100 | ); 101 | } 102 | } 103 | 104 | function testReceive721() public { 105 | erc721.mint(address(timelock), 1); 106 | 107 | assertEq( 108 | erc721.ownerOf(1), 109 | address(timelock), 110 | "id does not have correct owner" 111 | ); 112 | } 113 | 114 | function testReceive721Safe() public { 115 | erc721.safeMint(address(timelock), 1); 116 | 117 | assertEq( 118 | erc721.ownerOf(1), 119 | address(timelock), 120 | "id does not have correct owner" 121 | ); 122 | } 123 | 124 | function testReceiveEth() public { 125 | uint256 amount = 1 ether; 126 | vm.deal(address(this), amount); 127 | 128 | address payable timelockPayable = payable(address(timelock)); 129 | timelockPayable.transfer(amount); 130 | 131 | assertEq( 132 | address(timelock).balance, 133 | amount, 134 | "timelock does not have correct balance" 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /test/utils/CallHelper.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.13; 2 | 3 | import {Test} from "forge-std/Test.sol"; 4 | 5 | import {Guard} from "src/Guard.sol"; 6 | import {Timelock} from "src/Timelock.sol"; 7 | 8 | contract CallHelper is Test { 9 | /** 10 | * Guard events * 11 | */ 12 | 13 | /// @notice Emitted when the guard is removed from a safe 14 | /// @param safe address of the safe 15 | event GuardDisabled(address indexed safe); 16 | 17 | /// @notice Emitted when the guard is added to a safe 18 | /// @param safe address of the safe 19 | event GuardEnabled(address indexed safe); 20 | 21 | /** 22 | * Timelock events * 23 | */ 24 | 25 | /// @notice Emitted when a call is scheduled as part of operation `id`. 26 | /// @param id unique identifier for the operation 27 | /// @param index index of the call within the operation, non zero if not first call in a batch 28 | /// @param target the address of the contract to call 29 | /// @param value the amount of native asset to send with the call 30 | /// @param data the calldata to send with the call 31 | /// @param salt the salt to be used in the operation 32 | /// @param delay the delay before the operation becomes valid 33 | event CallScheduled( 34 | bytes32 indexed id, 35 | uint256 indexed index, 36 | address indexed target, 37 | uint256 value, 38 | bytes data, 39 | bytes32 salt, 40 | uint256 delay 41 | ); 42 | 43 | /// @notice Emitted when a call is performed as part of operation `id`. 44 | /// @param id unique identifier for the operation 45 | /// @param index index of the call within the operation, non zero if not first call in a batch 46 | /// @param target the address of the contract called 47 | /// @param value the amount of native asset sent with the call 48 | /// @param data the calldata sent with the call 49 | event CallExecuted( 50 | bytes32 indexed id, 51 | uint256 indexed index, 52 | address target, 53 | uint256 value, 54 | bytes data 55 | ); 56 | 57 | /** 58 | * Timelock helper functions to check emitted events * 59 | */ 60 | function _schedule( 61 | address caller, 62 | address timelock, 63 | address target, 64 | uint256 value, 65 | bytes memory data, 66 | bytes32 salt, 67 | uint256 delay 68 | ) internal { 69 | bytes32 id = 70 | Timelock(payable(timelock)).hashOperation(target, value, data, salt); 71 | vm.expectEmit(true, true, true, true, timelock); 72 | emit CallScheduled(id, 0, target, value, data, salt, delay); 73 | 74 | vm.prank(caller); 75 | Timelock(payable(timelock)).schedule(target, value, data, salt, delay); 76 | 77 | assertEq( 78 | Timelock(payable(timelock)).timestamps(id), 79 | block.timestamp + delay, 80 | "timestamps should equal block timestamp" 81 | ); 82 | } 83 | 84 | function _scheduleBatch( 85 | address caller, 86 | address timelock, 87 | address[] memory targets, 88 | uint256[] memory values, 89 | bytes[] memory payloads, 90 | bytes32 salt, 91 | uint256 delay 92 | ) internal { 93 | bytes32 id = Timelock(payable(timelock)).hashOperationBatch( 94 | targets, values, payloads, salt 95 | ); 96 | vm.expectEmit(true, true, true, true, timelock); 97 | for (uint256 i = 0; i < targets.length; ++i) { 98 | emit CallScheduled( 99 | id, i, targets[i], values[i], payloads[i], salt, delay 100 | ); 101 | } 102 | 103 | vm.prank(caller); 104 | Timelock(payable(timelock)).scheduleBatch( 105 | targets, values, payloads, salt, delay 106 | ); 107 | } 108 | 109 | function _execute( 110 | address caller, 111 | address timelock, 112 | address target, 113 | uint256 value, 114 | bytes memory payload, 115 | bytes32 salt 116 | ) internal { 117 | bytes32 id = Timelock(payable(timelock)).hashOperation( 118 | target, value, payload, salt 119 | ); 120 | vm.expectEmit(true, true, true, true, timelock); 121 | emit CallExecuted(id, 0, target, value, payload); 122 | 123 | vm.prank(caller); 124 | Timelock(payable(timelock)).execute(target, value, payload, salt); 125 | } 126 | 127 | function _executeBatch( 128 | address caller, 129 | address timelock, 130 | address[] memory targets, 131 | uint256[] memory values, 132 | bytes[] memory payloads, 133 | bytes32 salt 134 | ) internal { 135 | bytes32 id = Timelock(payable(timelock)).hashOperationBatch( 136 | targets, values, payloads, salt 137 | ); 138 | for (uint256 i = 0; i < targets.length; ++i) { 139 | vm.expectEmit(true, true, true, true, timelock); 140 | emit CallExecuted(id, i, targets[i], values[i], payloads[i]); 141 | } 142 | 143 | vm.prank(caller); 144 | Timelock(payable(timelock)).executeBatch( 145 | targets, values, payloads, salt 146 | ); 147 | } 148 | 149 | function _executeWhiteListed( 150 | address caller, 151 | address timelock, 152 | address target, 153 | uint256 value, 154 | bytes memory payload 155 | ) internal { 156 | vm.expectEmit(true, true, true, true, timelock); 157 | emit CallExecuted(bytes32(0), 0, target, value, payload); 158 | 159 | vm.prank(caller); 160 | Timelock(payable(timelock)).executeWhitelisted(target, value, payload); 161 | } 162 | 163 | function _executeWhitelistedBatch( 164 | address caller, 165 | address timelock, 166 | address[] memory targets, 167 | uint256[] memory values, 168 | bytes[] memory payloads 169 | ) internal { 170 | for (uint256 i = 0; i < targets.length; ++i) { 171 | vm.expectEmit(true, true, true, true, timelock); 172 | emit CallExecuted(bytes32(0), i, targets[i], values[i], payloads[i]); 173 | } 174 | 175 | vm.prank(caller); 176 | Timelock(payable(timelock)).executeWhitelistedBatch( 177 | targets, values, payloads 178 | ); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /test/utils/NestedArrayHelper.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | function generateCalldatas( 4 | bytes[][] memory calldatas, 5 | bytes memory data, 6 | uint256 index 7 | ) pure returns (bytes[][] memory) { 8 | bytes[] memory dataArray = new bytes[](1); 9 | dataArray[0] = data; 10 | calldatas[index] = dataArray; 11 | return calldatas; 12 | } 13 | 14 | function generateCalldatasWildcard( 15 | bytes[][] memory calldatas, 16 | bytes memory, 17 | uint256 index 18 | ) pure returns (bytes[][] memory) { 19 | calldatas[index] = new bytes[](0); 20 | return calldatas; 21 | } 22 | -------------------------------------------------------------------------------- /test/utils/SigHelper.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {Test} from "forge-std/Test.sol"; 4 | 5 | abstract contract SigHelper is Test { 6 | function signTx(bytes32 transactionHash, uint256 _pk) 7 | internal 8 | pure 9 | returns (bytes memory) 10 | { 11 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(_pk, transactionHash); 12 | return abi.encodePacked(r, s, v); 13 | } 14 | 15 | function signTxAllOwners( 16 | bytes32 transactionHash, 17 | uint256 _pk1, 18 | uint256 _pk2, 19 | uint256 _pk3 20 | ) internal pure returns (bytes memory) { 21 | return abi.encodePacked( 22 | signTx(transactionHash, _pk1), 23 | signTx(transactionHash, _pk2), 24 | signTx(transactionHash, _pk3) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/utils/SystemIntegrationFixture.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {IERC1155Receiver} from 4 | "@openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sol"; 5 | import {SafeProxyFactory} from "@safe/proxies/SafeProxyFactory.sol"; 6 | import {IERC721Receiver} from 7 | "@openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol"; 8 | import { 9 | IERC165, 10 | ERC165 11 | } from "@openzeppelin-contracts/contracts/utils/introspection/ERC165.sol"; 12 | import {IERC20} from "@openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; 13 | import {FallbackManager} from "@safe/base/FallbackManager.sol"; 14 | import {ModuleManager} from "@safe/base/ModuleManager.sol"; 15 | import {GuardManager} from "@safe/base/GuardManager.sol"; 16 | import {OwnerManager} from "@safe/base/OwnerManager.sol"; 17 | import {IMulticall3} from "@interface/IMulticall3.sol"; 18 | import {Addresses} from "@forge-proposal-simulator/addresses/Addresses.sol"; 19 | import {SafeProxy} from "@safe/proxies/SafeProxy.sol"; 20 | import {SafeL2} from "@safe/SafeL2.sol"; 21 | import {Enum} from "@safe/common/Enum.sol"; 22 | import {Safe} from "@safe/Safe.sol"; 23 | import { 24 | IMorpho, 25 | Position, 26 | IMorphoBase, 27 | MarketParams 28 | } from "src/interface/IMorpho.sol"; 29 | 30 | import {Test, stdError, console} from "forge-std/Test.sol"; 31 | 32 | import "src/utils/Constants.sol"; 33 | 34 | import {Guard} from "src/Guard.sol"; 35 | import {Timelock} from "src/Timelock.sol"; 36 | import {BytesHelper} from "src/BytesHelper.sol"; 37 | import {SigHelper} from "test/utils/SigHelper.sol"; 38 | import {RecoverySpell} from "src/RecoverySpell.sol"; 39 | import {SystemDeploy} from "src/deploy/SystemDeploy.s.sol"; 40 | import {RecoverySpellFactory} from "src/RecoverySpellFactory.sol"; 41 | import {AddressCalculation} from "src/views/AddressCalculation.sol"; 42 | import {TimelockFactory, DeploymentParams} from "src/TimelockFactory.sol"; 43 | import { 44 | InstanceDeployer, 45 | NewInstance, 46 | SystemInstance 47 | } from "src/InstanceDeployer.sol"; 48 | 49 | contract SystemIntegrationFixture is Test, SigHelper, SystemDeploy { 50 | using BytesHelper for bytes; 51 | 52 | /// @notice reference to the Timelock contract 53 | Timelock public timelock; 54 | 55 | /// @notice reference to the deployed Safe contract 56 | SafeL2 public safe; 57 | 58 | /// @notice reference to the Guard contract 59 | Guard public guard; 60 | 61 | /// @notice reference to the instance deployer 62 | InstanceDeployer public deployer; 63 | 64 | /// @notice reference to the AddressCalculation contract 65 | AddressCalculation public addressCalculation; 66 | 67 | /// @notice reference to the RecoverySpellFactory contract 68 | RecoverySpellFactory public recoveryFactory; 69 | 70 | /// @notice reference to the TimelockFactory contract 71 | TimelockFactory public timelockFactory; 72 | 73 | /// @notice the 3 hot signers that can execute whitelisted actions 74 | address[] public hotSigners; 75 | 76 | /// @notice address of the guardian that can pause in case of emergency 77 | address public guardian = address(0x11111); 78 | 79 | /// @notice duration of pause 80 | uint128 public constant PAUSE_DURATION = 10 days; 81 | 82 | /// @notice minimum delay for a timelocked transaction in seconds 83 | uint256 public constant MINIMUM_DELAY = 2 days; 84 | 85 | /// @notice expiration period for a timelocked transaction in seconds 86 | uint256 public constant EXPIRATION_PERIOD = 5 days; 87 | 88 | /// @notice number of signatures required on the gnosis safe 89 | uint256 public constant QUORUM = 2; 90 | 91 | /// @notice first public key 92 | uint256 public constant pk1 = 4; 93 | 94 | /// @notice second public key 95 | uint256 public constant pk2 = 2; 96 | 97 | /// @notice third public key 98 | uint256 public constant pk3 = 3; 99 | 100 | /// @notice address of the factory contract 101 | SafeProxyFactory public factory; 102 | 103 | /// @notice liquidation loan to value ratio 104 | uint256 public constant lltv = 915000000000000000; 105 | 106 | /// @notice storage slot for the guard 107 | /// keccak256("guard_manager.guard.address") 108 | uint256 public constant GUARD_STORAGE_SLOT = 109 | 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; 110 | 111 | /// @notice current owners 112 | address[] public owners; 113 | 114 | /// @notice 5 backup owners for the safe 115 | uint256[] public recoveryPrivateKeys; 116 | 117 | /// @notice 5 backup owners for the safe 118 | address[] public recoveryOwners; 119 | 120 | /// @notice backup threshold 121 | uint256 public constant recoveryThreshold = 3; 122 | 123 | /// @notice recovery delay time 124 | uint256 public constant recoveryDelay = 1 days; 125 | 126 | /// @notice salt for the recovery spell 127 | bytes32 public recoverySalt = 128 | 0x00000000000000001234567890abcdef00000000000000001234567890abcdef; 129 | 130 | /// @notice address of the recovery spell contract 131 | address public recoverySpellAddress; 132 | 133 | /// @notice address of the morpho blue contract 134 | address public morphoBlue; 135 | 136 | /// @notice logic contract for the safe 137 | address public logic; 138 | 139 | /// @notice logic contract for the L2 safe 140 | address public logicL2; 141 | 142 | /// @notice ethena USD contract 143 | address public ethenaUsd; 144 | 145 | /// @notice DAI contract 146 | address public dai; 147 | 148 | /// @notice WBTC contract 149 | address public wbtc; 150 | 151 | /// @notice WETH contract 152 | address public weth; 153 | 154 | /// @notice cDAI contract 155 | address public cDai; 156 | 157 | /// @notice cEther contract 158 | address public cEther; 159 | 160 | /// @notice morpho blue irm contract 161 | address public irm; 162 | 163 | /// @notice morpho blue oracle contract USDe Dai 164 | address public oracleEusdDai; 165 | 166 | /// @notice morpho blue oracle contract WBTC WETH 167 | address public oracleWbtcdWeth; 168 | 169 | /// @notice the multicall contract 170 | address public multicall; 171 | 172 | /// @notice time the test started 173 | uint256 public startTimestamp; 174 | 175 | /// @notice 2 owners need to sign to recover the safe 176 | uint256 public constant RECOVERY_THRESHOLD_OWNERS = 2; 177 | 178 | /// @notice the length of the market params in bytes 179 | uint256 constant MARKET_PARAMS_BYTES_LENGTH = 5 * 32; 180 | 181 | /// @notice addresses of the hot signers 182 | address public constant HOT_SIGNER_ONE = address(0x11111); 183 | address public constant HOT_SIGNER_TWO = address(0x22222); 184 | address public constant HOT_SIGNER_THREE = address(0x33333); 185 | 186 | /// @notice event emitted when the recovery is executed 187 | /// @param time the time the recovery was executed 188 | event SafeRecovered(uint256 indexed time); 189 | 190 | /// @param sender address that attempted to create the safe 191 | /// @param timestamp time the safe creation failed 192 | /// @param safeInitdata initialization data for the safe 193 | /// @param creationSalt salt used to create the safe 194 | event SafeCreationFailed( 195 | address indexed sender, 196 | uint256 indexed timestamp, 197 | address indexed safe, 198 | bytes safeInitdata, 199 | uint256 creationSalt 200 | ); 201 | 202 | function setUp() public { 203 | hotSigners.push(HOT_SIGNER_ONE); 204 | hotSigners.push(HOT_SIGNER_TWO); 205 | hotSigners.push(HOT_SIGNER_THREE); 206 | 207 | startTimestamp = block.timestamp; 208 | 209 | /// set addresses object in msig proposal 210 | uint256[] memory chainIds = new uint256[](3); 211 | chainIds[0] = 1; 212 | chainIds[1] = 8453; 213 | chainIds[2] = 84532; 214 | addresses = new Addresses("./addresses", chainIds); 215 | 216 | deploy(); 217 | 218 | factory = SafeProxyFactory(addresses.getAddress("SAFE_FACTORY")); 219 | morphoBlue = addresses.getAddress("MORPHO_BLUE"); 220 | logic = addresses.getAddress("SAFE_LOGIC"); 221 | logicL2 = addresses.getAddress("SAFE_LOGIC", 8453); 222 | ethenaUsd = addresses.getAddress("ETHENA_USD"); 223 | dai = addresses.getAddress("DAI"); 224 | wbtc = addresses.getAddress("WBTC"); 225 | weth = addresses.getAddress("WETH"); 226 | irm = addresses.getAddress("MORPHO_BLUE_IRM"); 227 | oracleEusdDai = addresses.getAddress("MORPHO_BLUE_EUSD_DAI_ORACLE"); 228 | oracleWbtcdWeth = addresses.getAddress("MORPHO_BLUE_WBTC_WETH_ORACLE"); 229 | multicall = addresses.getAddress("MULTICALL3"); 230 | cDai = addresses.getAddress("C_DAI"); 231 | cEther = addresses.getAddress("C_ETHER"); 232 | 233 | owners.push(vm.addr(pk1)); 234 | owners.push(vm.addr(pk2)); 235 | owners.push(vm.addr(pk3)); 236 | 237 | vm.label(owners[0], "Owner 1"); 238 | vm.label(owners[1], "Owner 2"); 239 | vm.label(owners[2], "Owner 3"); 240 | 241 | recoveryPrivateKeys.push(10); 242 | recoveryPrivateKeys.push(11); 243 | recoveryPrivateKeys.push(12); 244 | recoveryPrivateKeys.push(13); 245 | recoveryPrivateKeys.push(14); 246 | 247 | for (uint256 i = 0; i < recoveryPrivateKeys.length; i++) { 248 | recoveryOwners.push(vm.addr(recoveryPrivateKeys[i])); 249 | } 250 | 251 | guard = Guard(addresses.getAddress("GUARD")); 252 | recoveryFactory = 253 | RecoverySpellFactory(addresses.getAddress("RECOVERY_SPELL_FACTORY")); 254 | deployer = InstanceDeployer(addresses.getAddress("INSTANCE_DEPLOYER")); 255 | timelockFactory = 256 | TimelockFactory(addresses.getAddress("TIMELOCK_FACTORY")); 257 | addressCalculation = 258 | AddressCalculation(addresses.getAddress("ADDRESS_CALCULATION")); 259 | 260 | NewInstance memory instance = NewInstance( 261 | owners, 262 | QUORUM, 263 | /// no recovery spells for now 264 | new address[](0), 265 | DeploymentParams( 266 | MINIMUM_DELAY, 267 | EXPIRATION_PERIOD, 268 | guardian, 269 | PAUSE_DURATION, 270 | hotSigners, 271 | new address[](0), 272 | new bytes4[](0), 273 | new uint16[](0), 274 | new uint16[](0), 275 | new bytes[][](0), 276 | bytes32(0) 277 | ) 278 | ); 279 | 280 | SystemInstance memory calculatedInstance = 281 | addressCalculation.calculateAddress(instance); 282 | 283 | recoverySpellAddress = recoveryFactory.calculateAddress( 284 | recoverySalt, 285 | recoveryOwners, 286 | address(calculatedInstance.safe), 287 | recoveryThreshold, 288 | RECOVERY_THRESHOLD_OWNERS, 289 | recoveryDelay 290 | ); 291 | 292 | address[] memory recoverySpells = new address[](1); 293 | recoverySpells[0] = recoverySpellAddress; 294 | instance.recoverySpells = recoverySpells; 295 | 296 | vm.prank(HOT_SIGNER_ONE); 297 | SystemInstance memory walletInstance = 298 | deployer.createSystemInstance(instance); 299 | 300 | safe = SafeL2(payable(walletInstance.safe)); 301 | timelock = Timelock(payable(walletInstance.timelock)); 302 | 303 | vm.label(address(timelock), "Timelock"); 304 | vm.label(address(safe), "Safe"); 305 | } 306 | 307 | /// MORPHO Helper function 308 | 309 | /// @notice Returns the id of the market `marketParams`. 310 | function id(MarketParams memory marketParams) 311 | internal 312 | pure 313 | returns (bytes32 marketParamsId) 314 | { 315 | assembly ("memory-safe") { 316 | marketParamsId := 317 | keccak256(marketParams, MARKET_PARAMS_BYTES_LENGTH) 318 | } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /test/utils/TimelockUnitFixture.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.25; 2 | 3 | import {IERC1155Receiver} from 4 | "@openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sol"; 5 | import {IERC721Receiver} from 6 | "@openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sol"; 7 | import { 8 | IERC165, 9 | ERC165 10 | } from "@openzeppelin-contracts/contracts/utils/introspection/ERC165.sol"; 11 | 12 | import {Test, console} from "forge-std/Test.sol"; 13 | 14 | import {Timelock} from "src/Timelock.sol"; 15 | import {MockSafe} from "test/mock/MockSafe.sol"; 16 | import {CallHelper} from "test/utils/CallHelper.t.sol"; 17 | import {MockLending} from "test/mock/MockLending.sol"; 18 | import {MockReentrancyExecutor} from "test/mock/MockReentrancyExecutor.sol"; 19 | import {TimelockFactory, DeploymentParams} from "src/TimelockFactory.sol"; 20 | import { 21 | calculateCreate2Address, Create2Params 22 | } from "src/utils/Create2Helper.sol"; 23 | import { 24 | InstanceDeployer, 25 | NewInstance, 26 | SystemInstance 27 | } from "src/InstanceDeployer.sol"; 28 | import { 29 | _DONE_TIMESTAMP, 30 | MIN_DELAY, 31 | MIN_DELAY as MINIMUM_DELAY, 32 | MAX_DELAY 33 | } from "src/utils/Constants.sol"; 34 | 35 | contract TimelockUnitFixture is CallHelper { 36 | /// @notice reference to the Timelock contract 37 | Timelock public timelock; 38 | 39 | /// @notice timelock factory 40 | TimelockFactory public timelockFactory; 41 | 42 | /// @notice reference to the MockSafe contract 43 | MockSafe public safe; 44 | 45 | /// @notice the 3 hot signers that can execute whitelisted actions 46 | address[] public hotSigners; 47 | 48 | /// @notice address of the guardian that can pause in case of emergency 49 | address public guardian = address(0x11111); 50 | 51 | /// @notice duration of pause 52 | uint128 public constant PAUSE_DURATION = 10 days; 53 | 54 | /// @notice expiration period for a timelocked transaction in seconds 55 | uint256 public constant EXPIRATION_PERIOD = 5 days; 56 | 57 | /// @notice salt for timelock creation through the factory 58 | bytes32 public constant salt = keccak256(hex"3afe"); 59 | 60 | /// @notice addresses of the hot signers 61 | address public constant HOT_SIGNER_ONE = address(0x11111); 62 | address public constant HOT_SIGNER_TWO = address(0x22222); 63 | address public constant HOT_SIGNER_THREE = address(0x33333); 64 | 65 | function setUp() public { 66 | hotSigners.push(HOT_SIGNER_ONE); 67 | hotSigners.push(HOT_SIGNER_TWO); 68 | hotSigners.push(HOT_SIGNER_THREE); 69 | 70 | // at least start at unix timestamp of 1m so that block timestamp isn't 0 71 | vm.warp(block.timestamp + 1_000_000 + EXPIRATION_PERIOD); 72 | 73 | safe = new MockSafe(); 74 | 75 | timelockFactory = new TimelockFactory(); 76 | 77 | // Assume the necessary parameters for the constructor 78 | timelock = Timelock( 79 | payable( 80 | timelockFactory.createTimelock( 81 | address(safe), // _safe 82 | DeploymentParams( 83 | MINIMUM_DELAY, // _minDelay 84 | EXPIRATION_PERIOD, // _expirationPeriod 85 | guardian, // _pauser 86 | PAUSE_DURATION, // _pauseDuration 87 | hotSigners, 88 | new address[](0), 89 | new bytes4[](0), 90 | new uint16[](0), 91 | new uint16[](0), 92 | new bytes[][](0), 93 | salt 94 | ) 95 | ) 96 | ) 97 | ); 98 | 99 | timelock.initialize( 100 | new address[](0), 101 | new bytes4[](0), 102 | new uint16[](0), 103 | new uint16[](0), 104 | new bytes[][](0) 105 | ); 106 | } 107 | } 108 | --------------------------------------------------------------------------------