// SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.28; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {FeeSwitch} from "./FeeSwitch.sol"; /// @title ParamTimelock /// @notice Owner of FeeSwitch. Every change is announced and only applies after DELAY. /// @dev The point is observability: a compromised multisig becomes visible before it takes effect. /// Three actions go through the same queue: fee change, fee collector change, and handing /// FeeSwitch ownership to a successor (migration path). A matured request must be applied /// within GRACE_PERIOD, otherwise it expires: a request cannot be pre-armed and fired later /// without a fresh observation window. FeeSwitch is Ownable2Step, so executing an ownership /// transfer only nominates the successor: this timelock stays the owner until the successor /// calls FeeSwitch.acceptOwnership(). A mistyped successor never accepts and changes nothing. /// /// Migration to a new timelock never passes through an EOA: the successor timelock takes the /// nomination itself via acceptFeeSwitchOwnership(), so FeeSwitch is owned by a timelock at /// every point in time. A nomination that is not taken can be revoked by the owner at once /// (revokeFeeSwitchNomination): revoking a privilege needs no observation window, granting does. contract ParamTimelock is Ownable2Step { enum Action { SetFeeBps, SetFeeCollector, TransferFeeSwitchOwnership } struct Pending { uint256 readyAt; Action action; uint16 feeBps; // used by SetFeeBps address target; // used by SetFeeCollector and TransferFeeSwitchOwnership } uint256 public constant DELAY = 2 days; /// @notice A matured request stays executable for this long after readyAt, then it expires. uint256 public constant GRACE_PERIOD = 14 days; FeeSwitch public feeSwitch; mapping(bytes32 => Pending) public pending; error NotQueued(); error TooEarly(uint256 readyAt, uint256 nowTs); error Expired(uint256 expiresAt, uint256 nowTs); error AlreadyQueued(); error FeeSwitchAlreadySet(); error FeeSwitchNotSet(); error ZeroFeeSwitch(); error FeeSwitchNotOwnedByTimelock(address actualOwner); error FeeSwitchNotPendingForTimelock(address actualPendingOwner); error NoNomination(); error RenounceDisabled(); error ZeroTarget(); error FeeAboveCap(uint16 requested, uint16 max); event FeeSwitchBound(address indexed feeSwitch); event FeeSwitchNominationRevoked(address indexed nominee); event Queued(bytes32 indexed id, uint16 newFeeBps, uint256 readyAt); event FeeCollectorQueued(bytes32 indexed id, address newCollector, uint256 readyAt); event OwnershipTransferQueued(bytes32 indexed id, address newOwner, uint256 readyAt); event Executed(bytes32 indexed id, uint16 newFeeBps); event FeeCollectorExecuted(bytes32 indexed id, address newCollector); event OwnershipTransferExecuted(bytes32 indexed id, address newOwner); event Cancelled(bytes32 indexed id); /// @dev A request that can never execute would only occupy its id until cancelled, so nothing /// is accepted into the queue before FeeSwitch is bound. modifier feeSwitchBound() { if (address(feeSwitch) == address(0)) revert FeeSwitchNotSet(); _; } constructor(address owner_) Ownable(owner_) {} /// @notice Binds a FeeSwitch that was deployed with this timelock as its owner. /// @dev Fail fast: a wiring mistake must surface at deployment, not on the first execute two days later. function setFeeSwitch(FeeSwitch feeSwitch_) external onlyOwner { _checkUnbound(feeSwitch_); address actualOwner = feeSwitch_.owner(); if (actualOwner != address(this)) revert FeeSwitchNotOwnedByTimelock(actualOwner); _bind(feeSwitch_); } /// @notice Takes over a FeeSwitch whose previous owner (typically the outgoing timelock) has /// nominated this timelock via an executed TransferFeeSwitchOwnership request. /// @dev This is the migration path. Without it a successor timelock could never satisfy /// setFeeSwitch (it is only pendingOwner, not owner), and the only workaround would be a hop /// through an EOA that owns FeeSwitch with no delay at all. The observation window already /// ran on the outgoing timelock, so accepting is immediate. function acceptFeeSwitchOwnership(FeeSwitch feeSwitch_) external onlyOwner { _checkUnbound(feeSwitch_); address actualPendingOwner = feeSwitch_.pendingOwner(); if (actualPendingOwner != address(this)) revert FeeSwitchNotPendingForTimelock(actualPendingOwner); feeSwitch_.acceptOwnership(); _bind(feeSwitch_); } /// @notice Withdraws a standing nomination of a FeeSwitch successor. Immediate, like cancel. /// @dev An executed TransferFeeSwitchOwnership leaves the nominee free to accept at any later /// time, which is the same "pre-arm now, fire later" pattern GRACE_PERIOD exists to prevent. /// Ownable2Step treats transferOwnership(0) as clearing the pending owner. Revoking takes a /// privilege away, so it needs no observation window; granting one still goes through the queue. function revokeFeeSwitchNomination() external onlyOwner feeSwitchBound { address nominee = feeSwitch.pendingOwner(); if (nominee == address(0)) revert NoNomination(); feeSwitch.transferOwnership(address(0)); emit FeeSwitchNominationRevoked(nominee); } /// @dev The cap is checked at queue time: FeeSwitch would reject it on execute anyway, and a /// request that can never execute just occupies its id until cancelled. function queue(bytes32 id, uint16 newFeeBps) external onlyOwner feeSwitchBound { uint16 cap = feeSwitch.MAX_FEE_BPS(); if (newFeeBps > cap) revert FeeAboveCap(newFeeBps, cap); uint256 readyAt = _queue(id, Action.SetFeeBps, newFeeBps, address(0)); emit Queued(id, newFeeBps, readyAt); } /// @dev Zero is rejected at queue time for the same reason as the cap above. function queueFeeCollector(bytes32 id, address newCollector) external onlyOwner feeSwitchBound { if (newCollector == address(0)) revert ZeroTarget(); uint256 readyAt = _queue(id, Action.SetFeeCollector, 0, newCollector); emit FeeCollectorQueued(id, newCollector, readyAt); } /// @notice Announces handing FeeSwitch over to a successor owner (e.g. a new timelock). /// @dev Executing it nominates the successor as FeeSwitch's pending owner; a successor timelock /// takes over via acceptFeeSwitchOwnership(), any other successor via FeeSwitch.acceptOwnership(). /// Zero is rejected here as well: Ownable2Step would not revert on it but silently clear the /// pending owner, which is not a transfer (that is what revokeFeeSwitchNomination is for). function queueOwnershipTransfer(bytes32 id, address newOwner) external onlyOwner feeSwitchBound { if (newOwner == address(0)) revert ZeroTarget(); uint256 readyAt = _queue(id, Action.TransferFeeSwitchOwnership, 0, newOwner); emit OwnershipTransferQueued(id, newOwner, readyAt); } /// @dev Deliberately callable by anyone: the delay is the control, not the caller. function execute(bytes32 id) external { Pending memory p = pending[id]; if (p.readyAt == 0) revert NotQueued(); if (block.timestamp < p.readyAt) revert TooEarly(p.readyAt, block.timestamp); uint256 expiresAt = p.readyAt + GRACE_PERIOD; if (block.timestamp > expiresAt) revert Expired(expiresAt, block.timestamp); delete pending[id]; if (p.action == Action.SetFeeBps) { feeSwitch.setFeeBps(p.feeBps); emit Executed(id, p.feeBps); } else if (p.action == Action.SetFeeCollector) { feeSwitch.setFeeCollector(p.target); emit FeeCollectorExecuted(id, p.target); } else { // Two-step on the FeeSwitch side: this only sets the pending owner. feeSwitch.transferOwnership(p.target); emit OwnershipTransferExecuted(id, p.target); } } function cancel(bytes32 id) external onlyOwner { if (pending[id].readyAt == 0) revert NotQueued(); delete pending[id]; emit Cancelled(id); } /// @dev Disabled: with a non-empty queue, an ownerless timelock means matured requests that /// nobody can cancel and a FeeSwitch that can never be reconfigured again. function renounceOwnership() public pure override { revert RenounceDisabled(); } function pendingAt(bytes32 id) external view returns (uint256) { return pending[id].readyAt; } function pendingFeeBps(bytes32 id) external view returns (uint16) { return pending[id].feeBps; } function _queue(bytes32 id, Action action, uint16 feeBps, address target) private returns (uint256 readyAt) { if (pending[id].readyAt != 0) revert AlreadyQueued(); readyAt = block.timestamp + DELAY; pending[id] = Pending({readyAt: readyAt, action: action, feeBps: feeBps, target: target}); } /// @dev Binding is one-shot: both entry points share the same preconditions. function _checkUnbound(FeeSwitch feeSwitch_) private view { if (address(feeSwitch) != address(0)) revert FeeSwitchAlreadySet(); if (address(feeSwitch_) == address(0)) revert ZeroFeeSwitch(); } function _bind(FeeSwitch feeSwitch_) private { feeSwitch = feeSwitch_; emit FeeSwitchBound(address(feeSwitch_)); } }