From 9c7dcb708b906c1a83200e9186420c2b51c17a6c Mon Sep 17 00:00:00 2001 From: scolear Date: Tue, 8 Oct 2024 16:33:44 +0200 Subject: [PATCH] wip: timelocked contract upgrades --- contracts/TimelockController.sol | 505 +++++++++++++++++++++++++++++++ scripts/99_contract-configs.js | 55 +++- scripts/helpers/utils.js | 27 ++ 3 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 contracts/TimelockController.sol diff --git a/contracts/TimelockController.sol b/contracts/TimelockController.sol new file mode 100644 index 0000000..a82a6f1 --- /dev/null +++ b/contracts/TimelockController.sol @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.9.0) (governance/TimelockController.sol) + +pragma solidity 0.8.18; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; + +/** + * @dev Contract module which acts as a timelocked controller. When set as the + * owner of an `Ownable` smart contract, it enforces a timelock on all + * `onlyOwner` maintenance operations. This gives time for users of the + * controlled contract to exit before a potentially dangerous maintenance + * operation is applied. + * + * By default, this contract is self administered, meaning administration tasks + * have to go through the timelock process. The proposer (resp executor) role + * is in charge of proposing (resp executing) operations. A common use case is + * to position this {TimelockController} as the owner of a smart contract, with + * a multisig or a DAO as the sole proposer. + * + * _Available since v3.3._ + */ +contract TimelockController is + AccessControl, + IERC721Receiver, + IERC1155Receiver +{ + bytes32 public constant TIMELOCK_ADMIN_ROLE = + keccak256("TIMELOCK_ADMIN_ROLE"); + bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + bytes32 public constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); + uint256 internal constant _DONE_TIMESTAMP = uint256(1); + + mapping(bytes32 => uint256) private _timestamps; + uint256 private _minDelay; + + /** + * @dev Emitted when a call is scheduled as part of operation `id`. + */ + event CallScheduled( + bytes32 indexed id, + uint256 indexed index, + address target, + uint256 value, + bytes data, + bytes32 predecessor, + uint256 delay + ); + + /** + * @dev Emitted when a call is performed as part of operation `id`. + */ + event CallExecuted( + bytes32 indexed id, + uint256 indexed index, + address target, + uint256 value, + bytes data + ); + + /** + * @dev Emitted when new proposal is scheduled with non-zero salt. + */ + event CallSalt(bytes32 indexed id, bytes32 salt); + + /** + * @dev Emitted when operation `id` is cancelled. + */ + event Cancelled(bytes32 indexed id); + + /** + * @dev Emitted when the minimum delay for future operations is modified. + */ + event MinDelayChange(uint256 oldDuration, uint256 newDuration); + + /** + * @dev Initializes the contract with the following parameters: + * + * - `minDelay`: initial minimum delay for operations + * - `proposers`: accounts to be granted proposer and canceller roles + * - `executors`: accounts to be granted executor role + * - `admin`: optional account to be granted admin role; disable with zero address + * + * IMPORTANT: The optional admin can aid with initial configuration of roles after deployment + * without being subject to delay, but this role should be subsequently renounced in favor of + * administration through timelocked proposals. Previous versions of this contract would assign + * this admin to the deployer automatically and should be renounced as well. + */ + constructor( + uint256 minDelay, + address[] memory proposers, + address[] memory executors, + address admin + ) { + _setRoleAdmin(TIMELOCK_ADMIN_ROLE, TIMELOCK_ADMIN_ROLE); + _setRoleAdmin(PROPOSER_ROLE, TIMELOCK_ADMIN_ROLE); + _setRoleAdmin(EXECUTOR_ROLE, TIMELOCK_ADMIN_ROLE); + _setRoleAdmin(CANCELLER_ROLE, TIMELOCK_ADMIN_ROLE); + + // self administration + _setupRole(TIMELOCK_ADMIN_ROLE, address(this)); + + // optional admin + if (admin != address(0)) { + _setupRole(TIMELOCK_ADMIN_ROLE, admin); + } + + // register proposers and cancellers + for (uint256 i = 0; i < proposers.length; ++i) { + _setupRole(PROPOSER_ROLE, proposers[i]); + _setupRole(CANCELLER_ROLE, proposers[i]); + } + + // register executors + for (uint256 i = 0; i < executors.length; ++i) { + _setupRole(EXECUTOR_ROLE, executors[i]); + } + + _minDelay = minDelay; + emit MinDelayChange(0, minDelay); + } + + /** + * @dev Modifier to make a function callable only by a certain role. In + * addition to checking the sender's role, `address(0)` 's role is also + * considered. Granting a role to `address(0)` is equivalent to enabling + * this role for everyone. + */ + modifier onlyRoleOrOpenRole(bytes32 role) { + if (!hasRole(role, address(0))) { + _checkRole(role, _msgSender()); + } + _; + } + + /** + * @dev Contract might receive/hold ETH as part of the maintenance process. + */ + receive() external payable {} + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(IERC165, AccessControl) returns (bool) { + return + interfaceId == type(IERC1155Receiver).interfaceId || + super.supportsInterface(interfaceId); + } + + /** + * @dev Returns whether an id correspond to a registered operation. This + * includes both Pending, Ready and Done operations. + */ + function isOperation(bytes32 id) public view virtual returns (bool) { + return getTimestamp(id) > 0; + } + + /** + * @dev Returns whether an operation is pending or not. Note that a "pending" operation may also be "ready". + */ + function isOperationPending(bytes32 id) public view virtual returns (bool) { + return getTimestamp(id) > _DONE_TIMESTAMP; + } + + /** + * @dev Returns whether an operation is ready for execution. Note that a "ready" operation is also "pending". + */ + function isOperationReady(bytes32 id) public view virtual returns (bool) { + uint256 timestamp = getTimestamp(id); + return timestamp > _DONE_TIMESTAMP && timestamp <= block.timestamp; + } + + /** + * @dev Returns whether an operation is done or not. + */ + function isOperationDone(bytes32 id) public view virtual returns (bool) { + return getTimestamp(id) == _DONE_TIMESTAMP; + } + + /** + * @dev Returns the timestamp at which an operation becomes ready (0 for + * unset operations, 1 for done operations). + */ + function getTimestamp(bytes32 id) public view virtual returns (uint256) { + return _timestamps[id]; + } + + /** + * @dev Returns the minimum delay for an operation to become valid. + * + * This value can be changed by executing an operation that calls `updateDelay`. + */ + function getMinDelay() public view virtual returns (uint256) { + return _minDelay; + } + + /** + * @dev Returns the identifier of an operation containing a single + * transaction. + */ + function hashOperation( + address target, + uint256 value, + bytes calldata data, + bytes32 predecessor, + bytes32 salt + ) public pure virtual returns (bytes32) { + return keccak256(abi.encode(target, value, data, predecessor, salt)); + } + + /** + * @dev Returns the identifier of an operation containing a batch of + * transactions. + */ + function hashOperationBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt + ) public pure virtual returns (bytes32) { + return + keccak256(abi.encode(targets, values, payloads, predecessor, salt)); + } + + /** + * @dev Schedule an operation containing a single transaction. + * + * Emits {CallSalt} if salt is nonzero, and {CallScheduled}. + * + * Requirements: + * + * - the caller must have the 'proposer' role. + */ + function schedule( + address target, + uint256 value, + bytes calldata data, + bytes32 predecessor, + bytes32 salt, + uint256 delay + ) public virtual onlyRole(PROPOSER_ROLE) { + bytes32 id = hashOperation(target, value, data, predecessor, salt); + _schedule(id, delay); + emit CallScheduled(id, 0, target, value, data, predecessor, delay); + if (salt != bytes32(0)) { + emit CallSalt(id, salt); + } + } + + /** + * @dev Schedule an operation containing a batch of transactions. + * + * Emits {CallSalt} if salt is nonzero, and one {CallScheduled} event per transaction in the batch. + * + * Requirements: + * + * - the caller must have the 'proposer' role. + */ + function scheduleBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt, + uint256 delay + ) public virtual onlyRole(PROPOSER_ROLE) { + require( + targets.length == values.length, + "TimelockController: length mismatch" + ); + require( + targets.length == payloads.length, + "TimelockController: length mismatch" + ); + + bytes32 id = hashOperationBatch( + targets, + values, + payloads, + predecessor, + salt + ); + _schedule(id, delay); + for (uint256 i = 0; i < targets.length; ++i) { + emit CallScheduled( + id, + i, + targets[i], + values[i], + payloads[i], + predecessor, + delay + ); + } + if (salt != bytes32(0)) { + emit CallSalt(id, salt); + } + } + + /** + * @dev Schedule an operation that is to become valid after a given delay. + */ + function _schedule(bytes32 id, uint256 delay) private { + require( + !isOperation(id), + "TimelockController: operation already scheduled" + ); + require( + delay >= getMinDelay(), + "TimelockController: insufficient delay" + ); + _timestamps[id] = block.timestamp + delay; + } + + /** + * @dev Cancel an operation. + * + * Requirements: + * + * - the caller must have the 'canceller' role. + */ + function cancel(bytes32 id) public virtual onlyRole(CANCELLER_ROLE) { + require( + isOperationPending(id), + "TimelockController: operation cannot be cancelled" + ); + delete _timestamps[id]; + + emit Cancelled(id); + } + + /** + * @dev Execute an (ready) operation containing a single transaction. + * + * Emits a {CallExecuted} event. + * + * Requirements: + * + * - the caller must have the 'executor' role. + */ + // This function can reenter, but it doesn't pose a risk because _afterCall checks that the proposal is pending, + // thus any modifications to the operation during reentrancy should be caught. + // slither-disable-next-line reentrancy-eth + function execute( + address target, + uint256 value, + bytes calldata payload, + bytes32 predecessor, + bytes32 salt + ) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) { + bytes32 id = hashOperation(target, value, payload, predecessor, salt); + + _beforeCall(id, predecessor); + _execute(target, value, payload); + emit CallExecuted(id, 0, target, value, payload); + _afterCall(id); + } + + /** + * @dev Execute an (ready) operation containing a batch of transactions. + * + * Emits one {CallExecuted} event per transaction in the batch. + * + * Requirements: + * + * - the caller must have the 'executor' role. + */ + // This function can reenter, but it doesn't pose a risk because _afterCall checks that the proposal is pending, + // thus any modifications to the operation during reentrancy should be caught. + // slither-disable-next-line reentrancy-eth + function executeBatch( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata payloads, + bytes32 predecessor, + bytes32 salt + ) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) { + require( + targets.length == values.length, + "TimelockController: length mismatch" + ); + require( + targets.length == payloads.length, + "TimelockController: length mismatch" + ); + + bytes32 id = hashOperationBatch( + targets, + values, + payloads, + predecessor, + salt + ); + + _beforeCall(id, predecessor); + for (uint256 i = 0; i < targets.length; ++i) { + address target = targets[i]; + uint256 value = values[i]; + bytes calldata payload = payloads[i]; + _execute(target, value, payload); + emit CallExecuted(id, i, target, value, payload); + } + _afterCall(id); + } + + /** + * @dev Execute an operation's call. + */ + function _execute( + address target, + uint256 value, + bytes calldata data + ) internal virtual { + (bool success, ) = target.call{value: value}(data); + require(success, "TimelockController: underlying transaction reverted"); + } + + /** + * @dev Checks before execution of an operation's calls. + */ + function _beforeCall(bytes32 id, bytes32 predecessor) private view { + require( + isOperationReady(id), + "TimelockController: operation is not ready" + ); + require( + predecessor == bytes32(0) || isOperationDone(predecessor), + "TimelockController: missing dependency" + ); + } + + /** + * @dev Checks after execution of an operation's calls. + */ + function _afterCall(bytes32 id) private { + require( + isOperationReady(id), + "TimelockController: operation is not ready" + ); + _timestamps[id] = _DONE_TIMESTAMP; + } + + /** + * @dev Changes the minimum timelock duration for future operations. + * + * Emits a {MinDelayChange} event. + * + * Requirements: + * + * - the caller must be the timelock itself. This can only be achieved by scheduling and later executing + * an operation where the timelock is the target and the data is the ABI-encoded call to this function. + */ + function updateDelay(uint256 newDelay) external virtual { + require( + msg.sender == address(this), + "TimelockController: caller must be timelock" + ); + emit MinDelayChange(_minDelay, newDelay); + _minDelay = newDelay; + } + + /** + * @dev See {IERC721Receiver-onERC721Received}. + */ + function onERC721Received( + address, + address, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } + + /** + * @dev See {IERC1155Receiver-onERC1155Received}. + */ + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155Received.selector; + } + + /** + * @dev See {IERC1155Receiver-onERC1155BatchReceived}. + */ + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public virtual override returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} diff --git a/scripts/99_contract-configs.js b/scripts/99_contract-configs.js index fe0e0ab..29716c8 100644 --- a/scripts/99_contract-configs.js +++ b/scripts/99_contract-configs.js @@ -4,7 +4,11 @@ const { saveDeploymentInfo, deploymentInfo, } = require('./helpers/deployment-handlers_versioned'); -const { promptUser, loadContractAddress } = require('./helpers/utils'); +const { + promptUser, + loadContractAddress, + getMinimumDelay, +} = require('./helpers/utils'); // This is a pure function that just logs async function beforeDeployment(contractName, constructorArguments, network) { @@ -37,6 +41,15 @@ module.exports = function getContractConfigs(networkConfig, _btcFeeRecipient) { const btcFeeRecipient = _btcFeeRecipient ?? '0014e60f61fa2f2941217934d5f9976bf27381b3b036'; const threshold = 2; + const minimumDelay = getMinimumDelay(networkName); + const proposers = [dlcAdminSafes.critical]; + const executors = [dlcAdminSafes.critical, deployer.address]; + const timelockConstructorArgs = [ + minimumDelay, + proposers, + executors, + hardhat.ethers.constants.AddressZero, // no admin + ]; return [ { @@ -66,6 +79,46 @@ module.exports = function getContractConfigs(networkConfig, _btcFeeRecipient) { }); }, }, + { + name: 'TimelockController', + deployer: deployer.address, + upgradeable: false, + requirements: [], + deploy: async (requirementAddresses) => { + await beforeDeployment( + 'TimelockController', + timelockConstructorArgs, + networkName + ); + + const TimelockController = + await hardhat.ethers.getContractFactory( + 'TimelockController' + ); + const timelockController = await TimelockController.deploy( + ...timelockConstructorArgs + ); + await timelockController.deployed(); + + await afterDeployment( + 'TimelockController', + timelockController, + networkName + ); + + return timelockController.address; + }, + verify: async () => { + const address = await loadContractAddress( + 'TimelockController', + networkName + ); + await hardhat.run('verify:verify', { + address: address, + constructorArguments: timelockConstructorArgs, + }); + }, + }, { name: 'DLCManager', deployer: deployer.address, diff --git a/scripts/helpers/utils.js b/scripts/helpers/utils.js index 5947327..8628bc9 100644 --- a/scripts/helpers/utils.js +++ b/scripts/helpers/utils.js @@ -15,6 +15,19 @@ async function promptUser(message) { return response.continue; } +async function promptUserForNumber(message, defaultValue) { + if (process.env.CLI_MODE === 'noninteractive') { + return 0; + } + const response = await prompts({ + type: 'number', + name: 'number', + message, + initial: defaultValue, + }); + return response.number; +} + async function loadContractAddress(requirement, network) { const deployment = await loadDeploymentInfo(network, requirement); if (!deployment) { @@ -29,7 +42,21 @@ async function loadContractAddress(requirement, network) { return deployment.contract.address; } +function getMinimumDelay(networkName) { + switch (networkName) { + case 'localhost': + case 'arbsepolia': + case 'sepolia': + case 'basesepolia': + return 60 * 2; // 2 minutes + default: + return 60 * 60 * 48; // 48 hours + } +} + module.exports = { promptUser, + promptUserForNumber, loadContractAddress, + getMinimumDelay, };