From b769c58c8f3408beace141508802b6679edec6a9 Mon Sep 17 00:00:00 2001 From: adu Date: Tue, 10 Sep 2024 16:20:46 +0800 Subject: [PATCH] feat: deploy multisig wallets and timelock contract to address centralization related risks (#92) * feat: use script to deploy multisig wallet on exocore * forge install: safe-smart-account v1.3.0-libs.0 * fix: remapping * feat: add deployment script for exocore multisig * chore: add utils directory * feat: add circuit breaker role to customtimelockcontroller * fix: solhint * test: add fuzzing test for governance utilizing multisig and timelock * doc: add governance doc --- .gitmodules | 3 + docs/contract-governance.md | 36 ++ lib/safe-smart-account | 1 + remappings.txt | 3 +- script/12_RedeployClientChainGateway.s.sol | 5 +- script/14_CorrectBootstrapErrors.s.sol | 2 +- script/15_DeploySafeMulstisigWallet.s.sol | 72 ++++ script/2_DeployBoth.s.sol | 2 +- script/7_DeployBootstrap.s.sol | 5 +- script/BaseScript.sol | 2 +- script/deployedMultisigWallets.json | 18 + script/integration/1_DeployBootstrap.s.sol | 5 +- script/safe_contracts_on_exocore.json | 12 + src/storage/BootstrapStorage.sol | 2 +- src/{core => utils}/BeaconProxyBytecode.sol | 0 src/{core => utils}/CustomProxyAdmin.sol | 0 src/utils/CustomTimelockController.sol | 37 ++ test/foundry/ExocoreDeployer.t.sol | 2 +- test/foundry/Governance.t.sol | 418 ++++++++++++++++++++ test/foundry/unit/Bootstrap.t.sol | 5 +- test/foundry/unit/ClientChainGateway.t.sol | 2 +- test/foundry/unit/CustomProxyAdmin.t.sol | 2 +- 22 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 docs/contract-governance.md create mode 160000 lib/safe-smart-account create mode 100644 script/15_DeploySafeMulstisigWallet.s.sol create mode 100644 script/deployedMultisigWallets.json create mode 100644 script/safe_contracts_on_exocore.json rename src/{core => utils}/BeaconProxyBytecode.sol (100%) rename src/{core => utils}/CustomProxyAdmin.sol (100%) create mode 100644 src/utils/CustomTimelockController.sol create mode 100644 test/foundry/Governance.t.sol diff --git a/.gitmodules b/.gitmodules index db0806c1..332e5ecb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "lib/openzeppelin-contracts-upgradeable"] path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/safe-smart-account"] + path = lib/safe-smart-account + url = https://github.com/safe-global/safe-smart-account diff --git a/docs/contract-governance.md b/docs/contract-governance.md new file mode 100644 index 00000000..678b03b2 --- /dev/null +++ b/docs/contract-governance.md @@ -0,0 +1,36 @@ +# Governance + +## Overview + +The contract has privileged functions accessible only to the contract owner, excluding most user-facing functionalities. These functions are utilized for: + +- **Configuration**: Setting key contract parameters, such as the whitelist for stakeable assets. +- **Pause/Unpause**: Temporarily halting the contract in emergencies and resuming it once resolved. +- **Enable Messaging**: Activating or deactivating LayerZero messaging capabilities. +- **Upgradeability**: Transitioning the contract to a new implementation. + +The contract owner is effectively the protocol's governor, with governance primarily operating through the owner's proposal and execution of tasks. To facilitate governance and mitigate centralization risks, a two-tier governance structure is implemented for the contract owner: + +1. **CustomTimelockController**: A custom timelock controller contract that owns the business contract, allowing tasks to be proposed and executed through it. This custom controller features a circuit breaker role, enabling emergency contract pauses without waiting for the timelock period. +2. **Multisig**: Utilizing Safe Multisig wallets as the proposer/canceler, executor, circuit breaker, and even the admin of the custom timelock controller contract to avoid single point of failure. + +## `CustomTimelockController` + +`CustomTimelockController` is a custom timelock controller contract that inherits from the OpenZeppelin `TimelockController` contract. It has a special role named circuit breaker, which can be used to pause the contract in case of emergency without waiting for the timelock period(not applied to unpause). The main roles for the `CustomTimelockController` are: + +- **Proposer/Canceler**: Propose a new task or cancel a proposed task. +- **Executor**: Execute a task after the timelock period. +- **Circuit Breaker**: Pause the contract in case of emergency. +- **Admin**: Set the roles for the `CustomTimelockController`. + +## Safe Multisig + +We use the Safe Multisig wallets as the proposer/canceler, executor, circuit breaker and even the admin of the `CustomTimelockController`. For chains like Ethereum, we create the Safe Multisig wallets from deployed Safe proxy factory contract, and set the implementation as the deployed Safe. And for Exocore specifically, we deploy the set of Safe contracts, especially for the `GnosisSafeProxyFactory` and the `GnosisSafe`, `GnosisSafeL2` singletons. The deployed Safe contracts address can be found in the [deployment json file](../script/safe_contracts_on_exocore.json). + +## Governance Test + +We have some fuzzing tests to make sure governance works as expected. Please refer to the [governance test](../test/foundry/Governance.t.sol) for more details. + +## Governance in Production + +When the protocol is ready for production, we will set the Safe Multisig wallets as the proposer/canceler, executor, circuit breaker and even the admin of the `CustomTimelockController`, and manage our contracts through the timelock controller. At that time, we will decide the multisig wallet for each role, the threshold for each multisig wallet, the signers for each multisig wallet, and the timelock period for timelock controller. \ No newline at end of file diff --git a/lib/safe-smart-account b/lib/safe-smart-account new file mode 160000 index 00000000..767ef36b --- /dev/null +++ b/lib/safe-smart-account @@ -0,0 +1 @@ +Subproject commit 767ef36bba88bdbc0c9fe3708a4290cabef4c376 diff --git a/remappings.txt b/remappings.txt index 3bb6c03b..2f83cab2 100644 --- a/remappings.txt +++ b/remappings.txt @@ -9,4 +9,5 @@ forge-std/=lib/forge-std/src/ @layerzerolabs/lz-evm-oapp-v2=lib/LayerZero-v2/oapp/ @layerzerolabs/lz-evm-messagelib-v2=lib/LayerZero-v2/messagelib/ @beacon-oracle=lib/eigenlayer-beacon-oracle/ -solidity-bytes-utils/=lib/solidity-bytes-utils/ \ No newline at end of file +solidity-bytes-utils/=lib/solidity-bytes-utils/ +@safe-contracts/=lib/safe-smart-account/contracts/ \ No newline at end of file diff --git a/script/12_RedeployClientChainGateway.s.sol b/script/12_RedeployClientChainGateway.s.sol index e56edab6..5d07ba81 100644 --- a/script/12_RedeployClientChainGateway.s.sol +++ b/script/12_RedeployClientChainGateway.s.sol @@ -3,12 +3,13 @@ pragma solidity ^0.8.19; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import "../src/core/BeaconProxyBytecode.sol"; import {Bootstrap} from "../src/core/Bootstrap.sol"; import {ClientChainGateway} from "../src/core/ClientChainGateway.sol"; -import {CustomProxyAdmin} from "../src/core/CustomProxyAdmin.sol"; + import "../src/core/ExoCapsule.sol"; import {Vault} from "../src/core/Vault.sol"; +import "../src/utils/BeaconProxyBytecode.sol"; +import {CustomProxyAdmin} from "../src/utils/CustomProxyAdmin.sol"; import {BaseScript} from "./BaseScript.sol"; diff --git a/script/14_CorrectBootstrapErrors.s.sol b/script/14_CorrectBootstrapErrors.s.sol index df419c4a..829aaa73 100644 --- a/script/14_CorrectBootstrapErrors.s.sol +++ b/script/14_CorrectBootstrapErrors.s.sol @@ -6,9 +6,9 @@ import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.s import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import "../src/core/BeaconProxyBytecode.sol"; import {Bootstrap} from "../src/core/Bootstrap.sol"; import {ClientChainGateway} from "../src/core/ClientChainGateway.sol"; +import "../src/utils/BeaconProxyBytecode.sol"; import "../src/core/ExoCapsule.sol"; import {Vault} from "../src/core/Vault.sol"; diff --git a/script/15_DeploySafeMulstisigWallet.s.sol b/script/15_DeploySafeMulstisigWallet.s.sol new file mode 100644 index 00000000..a15b9a4c --- /dev/null +++ b/script/15_DeploySafeMulstisigWallet.s.sol @@ -0,0 +1,72 @@ +pragma solidity ^0.8.13; + +import {BaseScript} from "./BaseScript.sol"; + +import "@safe-contracts/GnosisSafeL2.sol"; +import "@safe-contracts/proxies/GnosisSafeProxyFactory.sol"; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; + +contract CreateMultisigScript is BaseScript { + + using stdJson for string; + + function setUp() public override { + super.setUp(); + + exocore = vm.createSelectFork(exocoreRPCURL); + _topUpPlayer(exocore, address(0), exocoreGenesis, deployer.addr, 2 ether); + } + + function run() public { + vm.selectFork(exocore); + vm.startBroadcast(deployer.privateKey); + + // Read deployed Safe contracts from JSON file + string memory json = vm.readFile("script/safe_contracts_on_exocore.json"); + + address proxyFactoryAddress = json.readAddress(".GnosisSafeProxyFactory"); + address safeSingletonAddress = json.readAddress(".GnosisSafeL2"); + address fallbackHandlerAddress = json.readAddress(".CompatibilityFallbackHandler"); + + GnosisSafeProxyFactory proxyFactory = GnosisSafeProxyFactory(proxyFactoryAddress); + GnosisSafeL2 safeSingleton = GnosisSafeL2(payable(safeSingletonAddress)); + + // Set up owners + address[] memory owners = new address[](3); + owners[0] = deployer.addr; + owners[1] = exocoreValidatorSet.addr; + owners[2] = relayer.addr; + + // Set up Safe parameters + uint256 threshold = 2; + address to = address(0); + bytes memory data = ""; + address fallbackHandler = fallbackHandlerAddress; + address paymentToken = address(0); + uint256 payment = 0; + address payable paymentReceiver = payable(address(0)); + + // Encode initialization data + bytes memory initializer = abi.encodeWithSelector( + GnosisSafe.setup.selector, + owners, + threshold, + to, + data, + fallbackHandler, + paymentToken, + payment, + paymentReceiver + ); + + // Create new Safe proxy + GnosisSafeProxy safeProxy = proxyFactory.createProxy(address(safeSingleton), initializer); + + console.log("New Safe created at:", address(safeProxy)); + + vm.stopBroadcast(); + } + +} diff --git a/script/2_DeployBoth.s.sol b/script/2_DeployBoth.s.sol index a7dcafc3..0fddec02 100644 --- a/script/2_DeployBoth.s.sol +++ b/script/2_DeployBoth.s.sol @@ -1,10 +1,10 @@ pragma solidity ^0.8.19; -import "../src/core/BeaconProxyBytecode.sol"; import "../src/core/ClientChainGateway.sol"; import "../src/core/ExoCapsule.sol"; import "../src/core/ExocoreGateway.sol"; import {Vault} from "../src/core/Vault.sol"; +import "../src/utils/BeaconProxyBytecode.sol"; import {ExocoreGatewayMock} from "../test/mocks/ExocoreGatewayMock.sol"; import {BaseScript} from "./BaseScript.sol"; diff --git a/script/7_DeployBootstrap.s.sol b/script/7_DeployBootstrap.s.sol index 7ecdbf45..643a57f2 100644 --- a/script/7_DeployBootstrap.s.sol +++ b/script/7_DeployBootstrap.s.sol @@ -3,12 +3,13 @@ pragma solidity ^0.8.19; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import "../src/core/BeaconProxyBytecode.sol"; import {Bootstrap} from "../src/core/Bootstrap.sol"; import {ClientChainGateway} from "../src/core/ClientChainGateway.sol"; -import {CustomProxyAdmin} from "../src/core/CustomProxyAdmin.sol"; + import "../src/core/ExoCapsule.sol"; import {Vault} from "../src/core/Vault.sol"; +import "../src/utils/BeaconProxyBytecode.sol"; +import {CustomProxyAdmin} from "../src/utils/CustomProxyAdmin.sol"; import {BaseScript} from "./BaseScript.sol"; import {ILayerZeroEndpointV2} from "@layerzero-v2/protocol/contracts/interfaces/ILayerZeroEndpointV2.sol"; diff --git a/script/BaseScript.sol b/script/BaseScript.sol index 2c0af011..8b347528 100644 --- a/script/BaseScript.sol +++ b/script/BaseScript.sol @@ -1,10 +1,10 @@ pragma solidity ^0.8.19; -import "../src/core/BeaconProxyBytecode.sol"; import "../src/interfaces/IClientChainGateway.sol"; import "../src/interfaces/IExoCapsule.sol"; import "../src/interfaces/IExocoreGateway.sol"; import "../src/interfaces/IVault.sol"; +import "../src/utils/BeaconProxyBytecode.sol"; import "../src/interfaces/precompiles/IAssets.sol"; import "../src/interfaces/precompiles/IClaimReward.sol"; diff --git a/script/deployedMultisigWallets.json b/script/deployedMultisigWallets.json new file mode 100644 index 00000000..711dfd94 --- /dev/null +++ b/script/deployedMultisigWallets.json @@ -0,0 +1,18 @@ +{ + "holesky": { + "multisig": "0x9A7b23a0BB29F77BaBB8add484b34c02EfD327BE", + "signers": [ + "0x481E020DB4709e6EdDbf8134D41b866c6Fc8555e", + "0x3583fF95f96b356d716881C871aF7Eb55ea34a93", + "0xA1dfab3234f49e02e04E6C56a021F1a497CD0f82" + ] + }, + "exocore": { + "multisig": "0xF27865277D8Cc608F279B3C09719A9Ceaa25A58f", + "signers": [ + "0x481E020DB4709e6EdDbf8134D41b866c6Fc8555e", + "0x3583fF95f96b356d716881C871aF7Eb55ea34a93", + "0xA1dfab3234f49e02e04E6C56a021F1a497CD0f82" + ] + } +} \ No newline at end of file diff --git a/script/integration/1_DeployBootstrap.s.sol b/script/integration/1_DeployBootstrap.s.sol index 750f31d0..d0833884 100644 --- a/script/integration/1_DeployBootstrap.s.sol +++ b/script/integration/1_DeployBootstrap.s.sol @@ -10,12 +10,13 @@ import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.so import {EndpointV2Mock} from "../../test/mocks/EndpointV2Mock.sol"; -import "../../src/core/BeaconProxyBytecode.sol"; import {Bootstrap} from "../../src/core/Bootstrap.sol"; -import {CustomProxyAdmin} from "../../src/core/CustomProxyAdmin.sol"; + import {Vault} from "../../src/core/Vault.sol"; import {IValidatorRegistry} from "../../src/interfaces/IValidatorRegistry.sol"; import {IVault} from "../../src/interfaces/IVault.sol"; +import "../../src/utils/BeaconProxyBytecode.sol"; +import {CustomProxyAdmin} from "../../src/utils/CustomProxyAdmin.sol"; import {MyToken} from "../../test/foundry/unit/MyToken.sol"; // Technically this is used for testing but it is marked as a script diff --git a/script/safe_contracts_on_exocore.json b/script/safe_contracts_on_exocore.json new file mode 100644 index 00000000..a8847a18 --- /dev/null +++ b/script/safe_contracts_on_exocore.json @@ -0,0 +1,12 @@ +{ + "SimulateTxAccessor": "0xB46E02a8c957892F7a1b7dD019bF7f56cA7830C6", + "GnosisSafeProxyFactory": "0xd92Eb22d59D2736C12ef8e009833b98dB812BC5F", + "DefaultCallbackHandler": "0xA9221e82f099027Da128369d323E249080507b78", + "CompatibilityFallbackHandler": "0x820ed29524601172Fe4aec900Bc48432067CBCDF", + "CreateCall": "0xA2c66e9eD611De51192EEfda6322E3D28b0c380c", + "MultiSend": "0x83Aa234126729346F9e6a33E109244935E521bEC", + "MultiSendCallOnly": "0x32A8c6b3c7D63002E1d230da5D525D6b6391796a", + "SignMessageLib": "0x30fdE0Cc889dEdD87cc11F48798506AbbC7B8c24", + "GnosisSafeL2": "0x9D24ad942d3453F574f3Df9C66504fDE009c14A0", + "GnosisSafe": "0xE28848a95D96dFc200A48f976b32B726253a8e14" +} \ No newline at end of file diff --git a/src/storage/BootstrapStorage.sol b/src/storage/BootstrapStorage.sol index c7d6b85f..2796ce50 100644 --- a/src/storage/BootstrapStorage.sol +++ b/src/storage/BootstrapStorage.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; -import {BeaconProxyBytecode} from "../core/BeaconProxyBytecode.sol"; import {Vault} from "../core/Vault.sol"; import {IValidatorRegistry} from "../interfaces/IValidatorRegistry.sol"; import {IVault} from "../interfaces/IVault.sol"; +import {BeaconProxyBytecode} from "../utils/BeaconProxyBytecode.sol"; import {Errors} from "../libraries/Errors.sol"; import {GatewayStorage} from "./GatewayStorage.sol"; diff --git a/src/core/BeaconProxyBytecode.sol b/src/utils/BeaconProxyBytecode.sol similarity index 100% rename from src/core/BeaconProxyBytecode.sol rename to src/utils/BeaconProxyBytecode.sol diff --git a/src/core/CustomProxyAdmin.sol b/src/utils/CustomProxyAdmin.sol similarity index 100% rename from src/core/CustomProxyAdmin.sol rename to src/utils/CustomProxyAdmin.sol diff --git a/src/utils/CustomTimelockController.sol b/src/utils/CustomTimelockController.sol new file mode 100644 index 00000000..292165f5 --- /dev/null +++ b/src/utils/CustomTimelockController.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; + +interface IPausable { + + function pause() external; + function unpause() external; + +} + +contract CustomTimelockController is TimelockController { + + bytes32 public constant CIRCUIT_BREAKER_ROLE = keccak256("CIRCUIT_BREAKER_ROLE"); + + constructor( + uint256 minDelay, + address[] memory proposers, + address[] memory executors, + address[] memory circuitBreakers, + address admin + ) TimelockController(minDelay, proposers, executors, admin) { + _setRoleAdmin(CIRCUIT_BREAKER_ROLE, TIMELOCK_ADMIN_ROLE); + + // Grant CIRCUIT_BREAKER_ROLE to the specified circuit breakers + for (uint256 i = 0; i < circuitBreakers.length; i++) { + _setupRole(CIRCUIT_BREAKER_ROLE, circuitBreakers[i]); + } + } + + function pause(address target) external onlyRole(CIRCUIT_BREAKER_ROLE) { + require(target != address(0), "CustomTimelockController: invalid target"); + IPausable(target).pause(); + } + +} diff --git a/test/foundry/ExocoreDeployer.t.sol b/test/foundry/ExocoreDeployer.t.sol index 7252a4d0..2d07edf6 100644 --- a/test/foundry/ExocoreDeployer.t.sol +++ b/test/foundry/ExocoreDeployer.t.sol @@ -32,8 +32,8 @@ import "../mocks/ClaimRewardMock.sol"; import "../mocks/DelegationMock.sol"; import {NonShortCircuitEndpointV2Mock} from "../mocks/NonShortCircuitEndpointV2Mock.sol"; -import "src/core/BeaconProxyBytecode.sol"; import "src/core/ExoCapsule.sol"; +import "src/utils/BeaconProxyBytecode.sol"; import "src/libraries/BeaconChainProofs.sol"; import "src/libraries/Endian.sol"; diff --git a/test/foundry/Governance.t.sol b/test/foundry/Governance.t.sol new file mode 100644 index 00000000..bb1fee3b --- /dev/null +++ b/test/foundry/Governance.t.sol @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "@safe-contracts/GnosisSafe.sol"; +import "@safe-contracts/GnosisSafeL2.sol"; +import "@safe-contracts/proxies/GnosisSafeProxyFactory.sol"; +import "forge-std/Test.sol"; +import "src/core/ClientChainGateway.sol"; +import "src/utils/CustomTimelockController.sol"; + +import "@beacon-oracle/contracts/src/EigenLayerBeaconOracle.sol"; + +import "@layerzero-v2/protocol/contracts/interfaces/ILayerZeroEndpointV2.sol"; + +import "@layerzero-v2/protocol/contracts/libs/AddressCast.sol"; + +import "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +import "src/core/ClientChainGateway.sol"; +import "src/storage/ClientChainGatewayStorage.sol"; + +import "src/core/ExoCapsule.sol"; +import {Vault} from "src/core/Vault.sol"; + +import {NonShortCircuitEndpointV2Mock} from "../mocks/NonShortCircuitEndpointV2Mock.sol"; +import "src/interfaces/IExoCapsule.sol"; +import "src/interfaces/IVault.sol"; + +import "src/utils/BeaconProxyBytecode.sol"; + +contract GovernanceTest is Test { + + struct Player { + uint256 privateKey; + address addr; + } + + struct Signature { + uint8 v; + bytes32 r; + bytes32 s; + } + + Player signer1; + Player signer2; + Player signer3; + + GnosisSafeL2 public safeImplementation; + GnosisSafeProxyFactory public safeProxyFactory; + GnosisSafeL2 public multisig; + CustomTimelockController public timelock; + + ERC20PresetFixedSupply restakeToken; + + ClientChainGateway clientGateway; + ClientChainGateway clientGatewayLogic; + ILayerZeroEndpointV2 clientChainLzEndpoint; + IBeaconChainOracle beaconOracle; + IVault vaultImplementation; + IExoCapsule capsuleImplementation; + IBeacon vaultBeacon; + IBeacon capsuleBeacon; + BeaconProxyBytecode beaconProxyBytecode; + + uint32 exocoreChainId = 2; + uint32 clientChainId = 1; + + uint256 holeskyFork; + + function setUp() public { + // Fork Holesky testnet + holeskyFork = vm.createSelectFork("https://ethereum-holesky.publicnode.com"); + + // Use already deployed Gnosis Safe contracts on Holesky + safeImplementation = GnosisSafeL2(payable(0x3E5c63644E683549055b9Be8653de26E0B4CD36E)); + safeProxyFactory = GnosisSafeProxyFactory(0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2); + address fallbackHandlerAddress = 0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4; + + // initialise players + signer1 = Player({privateKey: 1, addr: vm.addr(1)}); + signer2 = Player({privateKey: 2, addr: vm.addr(2)}); + signer3 = Player({privateKey: 3, addr: vm.addr(3)}); + + // Deploy 2-of-3 multisig + address[] memory owners = new address[](3); + owners[0] = signer1.addr; + owners[1] = signer2.addr; + owners[2] = signer3.addr; + + bytes memory initializer = abi.encodeWithSelector( + GnosisSafe.setup.selector, + owners, + 2, + address(0), + "", + fallbackHandlerAddress, + address(0), + 0, + payable(address(0)) + ); + + GnosisSafeProxy multisigProxy = + safeProxyFactory.createProxyWithNonce(address(safeImplementation), initializer, 0); + multisig = GnosisSafeL2(payable(address(multisigProxy))); + + // Deploy CustomTimelockController + address[] memory proposers = new address[](1); + address[] memory executors = new address[](1); + address[] memory circuitBreakers = new address[](1); + proposers[0] = address(multisig); + executors[0] = address(multisig); + circuitBreakers[0] = address(multisig); + + timelock = new CustomTimelockController( + 1 days, // minDelay + proposers, + executors, + circuitBreakers, + address(multisig) // admin + ); + + // Deploy and initialize ClientChainGateway + _deployClientChainGateway(address(timelock)); + } + + function _deployClientChainGateway(address owner) internal { + beaconOracle = IBeaconChainOracle(_deployBeaconOracle()); + + vaultImplementation = new Vault(); + capsuleImplementation = new ExoCapsule(); + + vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); + capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); + + beaconProxyBytecode = new BeaconProxyBytecode(); + + restakeToken = new ERC20PresetFixedSupply("rest", "rest", 1e16, owner); + + clientChainLzEndpoint = new NonShortCircuitEndpointV2Mock(clientChainId, owner); + ProxyAdmin proxyAdmin = new ProxyAdmin(); + clientGatewayLogic = new ClientChainGateway( + address(clientChainLzEndpoint), + exocoreChainId, + address(beaconOracle), + address(vaultBeacon), + address(capsuleBeacon), + address(beaconProxyBytecode) + ); + clientGateway = ClientChainGateway( + payable(address(new TransparentUpgradeableProxy(address(clientGatewayLogic), address(proxyAdmin), ""))) + ); + + clientGateway.initialize(payable(owner)); + } + + function _deployBeaconOracle() internal returns (EigenLayerBeaconOracle) { + uint256 GENESIS_BLOCK_TIMESTAMP; + + // mainnet + if (block.chainid == 1) { + GENESIS_BLOCK_TIMESTAMP = 1_606_824_023; + // goerli + } else if (block.chainid == 5) { + GENESIS_BLOCK_TIMESTAMP = 1_616_508_000; + // sepolia + } else if (block.chainid == 11_155_111) { + GENESIS_BLOCK_TIMESTAMP = 1_655_733_600; + // holesky + } else if (block.chainid == 17_000) { + GENESIS_BLOCK_TIMESTAMP = 1_695_902_400; + } else { + revert("Unsupported chainId."); + } + + EigenLayerBeaconOracle oracle = new EigenLayerBeaconOracle(GENESIS_BLOCK_TIMESTAMP); + return oracle; + } + + function testFuzz_MultisigCanPauseImmediately(uint8 signersMask) public { + vm.assume(signersMask > 0 && signersMask < 8); // Ensure at least one signer and constrain to 3 bits + + // Fork Holesky testnet + vm.selectFork(holeskyFork); + + // Prepare multisig transaction to call pause on timelock + bytes memory pauseData = abi.encodeWithSelector(CustomTimelockController.pause.selector, address(clientGateway)); + + // Use the fuzzed input to determine which signers to include + Player[] memory selectedSigners = selectSigners(signersMask); + + // Sign the transaction + bytes memory signatures = signMultisigTransaction(address(timelock), 0, pauseData, selectedSigners); + + // Execute multisig transaction if we have enough signers + if (selectedSigners.length >= 2) { + multisig.execTransaction( + address(timelock), + 0, + pauseData, + Enum.Operation.Call, + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken + payable(0), // refundReceiver + signatures + ); + + // Check if gateway is paused + assertTrue(clientGateway.paused(), "Gateway should be paused"); + } else { + // If we don't have enough signers, expect the transaction to revert + vm.expectRevert(); + multisig.execTransaction( + address(timelock), + 0, + pauseData, + Enum.Operation.Call, + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken + payable(0), // refundReceiver + signatures + ); + + // Check that the gateway is still not paused + assertFalse(clientGateway.paused(), "Gateway should not be paused"); + } + } + + function testFuzz_MultisigNeedsDelayToUnpause( + uint8 pauseSignersMask, + uint8 scheduleSignersMask, + uint8 executeSignersMask + ) public { + vm.assume(pauseSignersMask > 0 && pauseSignersMask < 8); + vm.assume(scheduleSignersMask > 0 && scheduleSignersMask < 8); + vm.assume(executeSignersMask > 0 && executeSignersMask < 8); + + // Fork Holesky testnet + vm.selectFork(holeskyFork); + + // First, pause the gateway + bytes memory pauseData = abi.encodeWithSelector(CustomTimelockController.pause.selector, address(clientGateway)); + Player[] memory pauseSigners = selectSigners(pauseSignersMask); + bytes memory pauseSignatures = signMultisigTransaction(address(timelock), 0, pauseData, pauseSigners); + + if (pauseSigners.length >= 2) { + multisig.execTransaction( + address(timelock), 0, pauseData, Enum.Operation.Call, 0, 0, 0, address(0), payable(0), pauseSignatures + ); + assertTrue(clientGateway.paused(), "Gateway should be paused"); + } else { + vm.expectRevert(); + multisig.execTransaction( + address(timelock), 0, pauseData, Enum.Operation.Call, 0, 0, 0, address(0), payable(0), pauseSignatures + ); + assertFalse(clientGateway.paused(), "Gateway should not be paused"); + return; // Exit the test if we couldn't pause + } + + // Prepare unpause data + bytes memory unpauseData = abi.encodeWithSelector(ClientChainGateway.unpause.selector); + + // Schedule unpause operation + bytes memory scheduleData = abi.encodeWithSelector( + TimelockController.schedule.selector, address(clientGateway), 0, unpauseData, bytes32(0), bytes32(0), 1 days + ); + Player[] memory scheduleSigners = selectSigners(scheduleSignersMask); + bytes memory scheduleSignatures = signMultisigTransaction(address(timelock), 0, scheduleData, scheduleSigners); + + if (scheduleSigners.length >= 2) { + multisig.execTransaction( + address(timelock), + 0, + scheduleData, + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(0), + scheduleSignatures + ); + } else { + vm.expectRevert(); + multisig.execTransaction( + address(timelock), + 0, + scheduleData, + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(0), + scheduleSignatures + ); + return; // Exit the test if we couldn't schedule + } + + // Try to execute immediately (should fail) + bytes memory executeData = abi.encodeWithSelector( + TimelockController.execute.selector, address(clientGateway), 0, unpauseData, bytes32(0), bytes32(0) + ); + Player[] memory executeSigners = selectSigners(executeSignersMask); + bytes memory executeSignatures = signMultisigTransaction(address(timelock), 0, executeData, executeSigners); + + // The transaction should revert because not enough signers or delay not passed + vm.expectRevert(); + multisig.execTransaction( + address(timelock), 0, executeData, Enum.Operation.Call, 0, 0, 0, address(0), payable(0), executeSignatures + ); + + // Wait for delay + vm.warp(block.timestamp + 1 days + 1); + + // Execute unpause operation + if (executeSigners.length >= 2) { + multisig.execTransaction( + address(timelock), + 0, + executeData, + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(0), + executeSignatures + ); + assertFalse(clientGateway.paused(), "Gateway should be unpaused"); + } else { + vm.expectRevert(); + multisig.execTransaction( + address(timelock), + 0, + executeData, + Enum.Operation.Call, + 0, + 0, + 0, + address(0), + payable(0), + executeSignatures + ); + assertTrue(clientGateway.paused(), "Gateway should still be paused"); + } + } + + function selectSigners(uint8 signersMask) internal view returns (Player[] memory) { + Player[] memory selectedSigners = new Player[](3); + uint256 signerCount = 0; + + if (signersMask & 1 != 0) { + selectedSigners[signerCount++] = signer1; + } + if (signersMask & 2 != 0) { + selectedSigners[signerCount++] = signer2; + } + if (signersMask & 4 != 0) { + selectedSigners[signerCount++] = signer3; + } + + // Resize the array to match the actual number of selected signers + assembly { + mstore(selectedSigners, signerCount) + } + + return selectedSigners; + } + + function signMultisigTransaction(address to, uint256 value, bytes memory data, Player[] memory signers) + internal + view + returns (bytes memory) + { + bytes32 txHash = multisig.getTransactionHash( + to, + value, + data, + Enum.Operation.Call, + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken + payable(0), // refundReceiver + multisig.nonce() + ); + + // Sort signers array based on address + for (uint256 i = 0; i < signers.length - 1; i++) { + for (uint256 j = 0; j < signers.length - i - 1; j++) { + if (signers[j].addr > signers[j + 1].addr) { + Player memory temp = signers[j]; + signers[j] = signers[j + 1]; + signers[j + 1] = temp; + } + } + } + + // Generate sorted signatures + bytes memory signatures; + for (uint256 i = 0; i < signers.length; i++) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signers[i].privateKey, txHash); + signatures = abi.encodePacked(signatures, r, s, v); + } + + // return signatures + return signatures; + } + +} diff --git a/test/foundry/unit/Bootstrap.t.sol b/test/foundry/unit/Bootstrap.t.sol index 35955bec..90b94c58 100644 --- a/test/foundry/unit/Bootstrap.t.sol +++ b/test/foundry/unit/Bootstrap.t.sol @@ -3,8 +3,9 @@ pragma solidity ^0.8.0; import {Bootstrap} from "src/core/Bootstrap.sol"; import {ClientChainGateway} from "src/core/ClientChainGateway.sol"; -import {CustomProxyAdmin} from "src/core/CustomProxyAdmin.sol"; + import {Vault} from "src/core/Vault.sol"; +import {CustomProxyAdmin} from "src/utils/CustomProxyAdmin.sol"; import {IValidatorRegistry} from "src/interfaces/IValidatorRegistry.sol"; @@ -28,9 +29,9 @@ import "forge-std/Test.sol"; import "forge-std/console.sol"; import "src/libraries/Errors.sol"; -import "src/core/BeaconProxyBytecode.sol"; import "src/core/ExoCapsule.sol"; import "src/storage/GatewayStorage.sol"; +import "src/utils/BeaconProxyBytecode.sol"; contract BootstrapTest is Test { diff --git a/test/foundry/unit/ClientChainGateway.t.sol b/test/foundry/unit/ClientChainGateway.t.sol index fb98139d..851eadf3 100644 --- a/test/foundry/unit/ClientChainGateway.t.sol +++ b/test/foundry/unit/ClientChainGateway.t.sol @@ -31,7 +31,7 @@ import {NonShortCircuitEndpointV2Mock} from "../../mocks/NonShortCircuitEndpoint import "src/interfaces/IExoCapsule.sol"; import "src/interfaces/IVault.sol"; -import "src/core/BeaconProxyBytecode.sol"; +import "src/utils/BeaconProxyBytecode.sol"; contract SetUp is Test { diff --git a/test/foundry/unit/CustomProxyAdmin.t.sol b/test/foundry/unit/CustomProxyAdmin.t.sol index 80107fe6..c7e57b34 100644 --- a/test/foundry/unit/CustomProxyAdmin.t.sol +++ b/test/foundry/unit/CustomProxyAdmin.t.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import {CustomProxyAdmin} from "src/core/CustomProxyAdmin.sol"; import {ICustomProxyAdmin} from "src/interfaces/ICustomProxyAdmin.sol"; +import {CustomProxyAdmin} from "src/utils/CustomProxyAdmin.sol"; import "forge-std/Test.sol"; import "forge-std/console.sol";