src/AirlockAdmin.sol400 lines · 18681 bytes
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity 0.8.37; |
| 3 | |
| 4 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; |
| 5 | import {ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; |
| 6 | |
| 7 | /// @title AirlockAdmin |
| 8 | /// @notice Owner of an OZ ProxyAdmin (and of the token's Ownable). Every upgrade, and every change to the airlock |
| 9 | /// itself, passes two doors before it can run: |
| 10 | /// 1. a cooldown of `delayBlocks` blocks during which any guardian can veto it, and |
| 11 | /// 2. a clearance: `clearanceOracle` (the Chainlink CRE report receiver) or the human `approver` must |
| 12 | /// record an evidence hash for the proposal. |
| 13 | /// A veto always wins over a clearance. `breakGlass` switches the proxy to a pre-registered paused |
| 14 | /// implementation immediately; leaving the paused state is an ordinary upgrade and passes both doors. |
| 15 | /// @dev Time is measured in block numbers only (never timestamps). The contract does not judge whether a new |
| 16 | /// implementation is good or bad; the clearance oracle does that off-chain and binds its verdict to |
| 17 | /// `proposalDigest(id)`, which includes the target's codehash. So that a verdict is about the code that |
| 18 | /// actually runs, an upgrade or break-glass target must be ordinary contract code: an EIP-7702 delegated |
| 19 | /// account (code `0xef0100 || delegate`) is refused, and `execute` refuses a target whose codehash differs |
| 20 | /// from the one recorded at propose time (`CodehashChanged`). |
| 21 | /// `oracleJudged[id]` records whether the clearance oracle itself has delivered a verdict (Clear or Veto) on |
| 22 | /// a proposal, apart from a manual clearance by the approver, so the oracle can still judge a proposal the |
| 23 | /// approver cleared (and veto it: a veto still wins). |
| 24 | /// Roles never overlap: the owner (who proposes) is neither the approver nor a guardian nor the clearance |
| 25 | /// oracle, so no single key can both propose and let its own proposal through. |
| 26 | contract AirlockAdmin { |
| 27 | // ---------------------------------------------------------------- constants |
| 28 | uint64 public constant MIN_DELAY_BLOCKS = 10; |
| 29 | uint64 public constant MAX_DELAY_BLOCKS = 50_400; |
| 30 | uint256 public constant MAX_GUARDIANS = 8; |
| 31 | uint256 public constant MIN_GUARDIANS = 1; |
| 32 | |
| 33 | // ---------------------------------------------------------------- enums |
| 34 | enum Kind { |
| 35 | Upgrade, |
| 36 | SetGuardian, |
| 37 | SetDelay, |
| 38 | SetPausedImpl |
| 39 | } |
| 40 | |
| 41 | enum Status { |
| 42 | None, |
| 43 | Pending, |
| 44 | Vetoed, |
| 45 | Executed |
| 46 | } |
| 47 | |
| 48 | struct Proposal { |
| 49 | Kind kind; |
| 50 | Status status; |
| 51 | address proposer; |
| 52 | address target; |
| 53 | uint256 value; |
| 54 | bytes32 codehash; |
| 55 | bytes32 dataHash; |
| 56 | uint64 proposedBlock; |
| 57 | uint64 readyBlock; |
| 58 | uint256 vetoCount; |
| 59 | } |
| 60 | |
| 61 | // ---------------------------------------------------------------- events |
| 62 | event Proposed( |
| 63 | uint256 indexed id, |
| 64 | uint8 indexed kind, |
| 65 | address indexed target, |
| 66 | uint256 value, |
| 67 | bytes data, |
| 68 | bytes32 codehash, |
| 69 | uint64 readyBlock |
| 70 | ); |
| 71 | event Vetoed(uint256 indexed id, address indexed guardian, bytes32 ruleHash, uint256 vetoCount); |
| 72 | event Cleared(uint256 indexed id, address indexed clearer, bytes32 evidenceHash); |
| 73 | event Executed(uint256 indexed id, address executor); |
| 74 | event ReasonAttached(uint256 indexed id, address guardian, bytes32 reportHash); |
| 75 | event CodehashApproved(bytes32 indexed codehash, address approver); |
| 76 | event BrokeGlass(address indexed caller, address pausedImpl); |
| 77 | event GuardianSet(address indexed guardian, bool enabled); |
| 78 | |
| 79 | // ---------------------------------------------------------------- errors |
| 80 | error NotOwner(); |
| 81 | error NotGuardian(); |
| 82 | error NotApprover(); |
| 83 | error NotClearer(); |
| 84 | error NotOwnerOrGuardian(); |
| 85 | error UnknownProposal(uint256 id); |
| 86 | error TooEarly(uint256 id, uint64 readyBlock, uint64 currentBlock); |
| 87 | error NotCleared(uint256 id); |
| 88 | error ProposalVetoed(uint256 id); |
| 89 | error AlreadyExecuted(uint256 id); |
| 90 | error EmptyEvidence(); |
| 91 | error InvalidDelay(uint64 value); |
| 92 | error GuardianBounds(uint256 count); |
| 93 | error RoleOverlap(); |
| 94 | error ZeroAddress(); |
| 95 | error NoCode(address target); |
| 96 | /// @notice `target` is an EIP-7702 delegated account: its code is a delegation designator, not the code that runs. |
| 97 | error DelegatedAccount(address target); |
| 98 | /// @notice The target's code is no longer the code proposal `id` was judged on. |
| 99 | error CodehashChanged(uint256 id, bytes32 proposedCodehash, bytes32 currentCodehash); |
| 100 | error AlreadyPaused(); |
| 101 | |
| 102 | // ---------------------------------------------------------------- immutable wiring |
| 103 | address public immutable proxyAdmin; |
| 104 | address public immutable proxy; |
| 105 | address public immutable owner; |
| 106 | address public immutable approver; |
| 107 | /// @notice The contract whose verdicts clear proposals (the CRE report receiver). |
| 108 | address public immutable clearanceOracle; |
| 109 | |
| 110 | // ---------------------------------------------------------------- state |
| 111 | uint64 public delayBlocks; |
| 112 | bool public paused; |
| 113 | address public pausedImpl; |
| 114 | uint256 public guardianCount; |
| 115 | uint256 public nextId = 1; // id 0 is reserved for "does not exist" |
| 116 | |
| 117 | mapping(address => bool) public isGuardian; |
| 118 | mapping(bytes32 => bool) public isApprovedCodehash; |
| 119 | /// @notice Whether `guardian` has already been counted in `vetoCount` of proposal `id`. |
| 120 | mapping(uint256 => mapping(address => bool)) public hasVetoed; |
| 121 | /// @notice Evidence hash of the first clearance of proposal `id`; zero while it is not cleared. |
| 122 | mapping(uint256 => bytes32) public clearanceOf; |
| 123 | /// @notice True once the clearance oracle itself has cleared or vetoed proposal `id`. A clearance by the approver |
| 124 | /// does not set it, so the oracle can still judge (and veto) a proposal the approver cleared by hand. |
| 125 | mapping(uint256 => bool) public oracleJudged; |
| 126 | |
| 127 | mapping(uint256 => Proposal) private _proposals; |
| 128 | mapping(uint256 => bytes) private _data; |
| 129 | |
| 130 | // ---------------------------------------------------------------- modifiers |
| 131 | modifier onlyOwner() { |
| 132 | if (msg.sender != owner) revert NotOwner(); |
| 133 | _; |
| 134 | } |
| 135 | |
| 136 | modifier onlyGuardian() { |
| 137 | if (!isGuardian[msg.sender]) revert NotGuardian(); |
| 138 | _; |
| 139 | } |
| 140 | |
| 141 | constructor( |
| 142 | address proxyAdmin_, |
| 143 | address proxy_, |
| 144 | address owner_, |
| 145 | address[] memory guardians_, |
| 146 | address approver_, |
| 147 | address pausedImpl_, |
| 148 | uint64 delayBlocks_, |
| 149 | address clearanceOracle_ |
| 150 | ) { |
| 151 | if ( |
| 152 | proxyAdmin_ == address(0) || proxy_ == address(0) || owner_ == address(0) || approver_ == address(0) |
| 153 | || pausedImpl_ == address(0) || clearanceOracle_ == address(0) |
| 154 | ) revert ZeroAddress(); |
| 155 | if (proxyAdmin_.code.length == 0) revert NoCode(proxyAdmin_); |
| 156 | if (proxy_.code.length == 0) revert NoCode(proxy_); |
| 157 | _requireContractCode(pausedImpl_); |
| 158 | if (clearanceOracle_.code.length == 0) revert NoCode(clearanceOracle_); |
| 159 | if (_isDelegated(clearanceOracle_)) revert DelegatedAccount(clearanceOracle_); // a key could sign as the oracle |
| 160 | if (approver_ == owner_ || clearanceOracle_ == owner_) revert RoleOverlap(); |
| 161 | _checkDelay(delayBlocks_); |
| 162 | |
| 163 | proxyAdmin = proxyAdmin_; |
| 164 | proxy = proxy_; |
| 165 | owner = owner_; |
| 166 | approver = approver_; |
| 167 | pausedImpl = pausedImpl_; |
| 168 | delayBlocks = delayBlocks_; |
| 169 | clearanceOracle = clearanceOracle_; |
| 170 | |
| 171 | uint256 count = 0; |
| 172 | for (uint256 i = 0; i < guardians_.length; i++) { |
| 173 | address g = guardians_[i]; |
| 174 | // forge-lint: disable-next-line(require-revert-in-loop) |
| 175 | if (g == address(0)) revert ZeroAddress(); |
| 176 | // forge-lint: disable-next-line(require-revert-in-loop) |
| 177 | if (g == owner_) revert RoleOverlap(); |
| 178 | if (isGuardian[g]) continue; // duplicates are counted once |
| 179 | isGuardian[g] = true; |
| 180 | count++; |
| 181 | emit GuardianSet(g, true); |
| 182 | } |
| 183 | if (count < MIN_GUARDIANS || count > MAX_GUARDIANS) revert GuardianBounds(count); |
| 184 | guardianCount = count; |
| 185 | } |
| 186 | |
| 187 | // ---------------------------------------------------------------- proposals (owner) |
| 188 | |
| 189 | function proposeUpgrade(address newImpl, bytes calldata data) external onlyOwner returns (uint256 id) { |
| 190 | _requireContractCode(newImpl); |
| 191 | id = _propose(Kind.Upgrade, newImpl, 0, newImpl.codehash, data); |
| 192 | } |
| 193 | |
| 194 | function proposeSetGuardian(address guardian, bool enabled) external onlyOwner returns (uint256 id) { |
| 195 | if (guardian == address(0)) revert ZeroAddress(); |
| 196 | if (enabled && guardian == owner) revert RoleOverlap(); // fail at propose time, not after the cooldown |
| 197 | id = _propose(Kind.SetGuardian, guardian, enabled ? 1 : 0, bytes32(0), ""); |
| 198 | } |
| 199 | |
| 200 | function proposeSetDelay(uint64 newDelay) external onlyOwner returns (uint256 id) { |
| 201 | _checkDelay(newDelay); |
| 202 | id = _propose(Kind.SetDelay, address(0), newDelay, bytes32(0), ""); |
| 203 | } |
| 204 | |
| 205 | function proposeSetPausedImpl(address impl) external onlyOwner returns (uint256 id) { |
| 206 | _requireContractCode(impl); |
| 207 | id = _propose(Kind.SetPausedImpl, impl, 0, impl.codehash, ""); |
| 208 | } |
| 209 | |
| 210 | // ---------------------------------------------------------------- clearance (oracle or approver) |
| 211 | |
| 212 | /// @notice Record that proposal `id` was judged safe. Only the clearance oracle or the approver may call it, |
| 213 | /// only while the proposal is Pending. Idempotent: a second clearance keeps the first evidence hash |
| 214 | /// (and still emits `Cleared` with the caller's evidence, so every clearance is observable). |
| 215 | /// A clearance never overrides a veto: once vetoed, the proposal can neither be cleared nor executed. |
| 216 | /// A clearance by the oracle also sets `oracleJudged[id]` (also when the approver cleared it first). |
| 217 | function clear(uint256 id, bytes32 evidenceHash) external { |
| 218 | bool byOracle = msg.sender == clearanceOracle; |
| 219 | if (!byOracle && msg.sender != approver) revert NotClearer(); |
| 220 | _requirePending(id); |
| 221 | if (evidenceHash == bytes32(0)) revert EmptyEvidence(); |
| 222 | if (clearanceOf[id] == bytes32(0)) clearanceOf[id] = evidenceHash; |
| 223 | if (byOracle) oracleJudged[id] = true; |
| 224 | emit Cleared(id, msg.sender, evidenceHash); |
| 225 | } |
| 226 | |
| 227 | // ---------------------------------------------------------------- execute (anyone) |
| 228 | |
| 229 | /// @notice Run a proposal once it is past its readyBlock, not vetoed, and cleared. Anyone may call it. |
| 230 | /// An upgrade or break-glass target must still have the codehash it had when it was proposed (the code |
| 231 | /// the verdict was about); otherwise it reverts `CodehashChanged` and the proposal stays Pending. |
| 232 | function execute(uint256 id) external { |
| 233 | Proposal storage p = _proposals[id]; |
| 234 | _requirePending(id); |
| 235 | uint64 current = _blockNumber(); |
| 236 | if (current < p.readyBlock) revert TooEarly(id, p.readyBlock, current); |
| 237 | if (clearanceOf[id] == bytes32(0)) revert NotCleared(id); |
| 238 | Kind kind = p.kind; |
| 239 | if (kind == Kind.Upgrade || kind == Kind.SetPausedImpl) { |
| 240 | bytes32 actual = p.target.codehash; |
| 241 | if (actual != p.codehash) revert CodehashChanged(id, p.codehash, actual); |
| 242 | } |
| 243 | |
| 244 | p.status = Status.Executed; // effects (and the event) before the external call |
| 245 | emit Executed(id, msg.sender); |
| 246 | |
| 247 | if (kind == Kind.Upgrade) { |
| 248 | ProxyAdmin(proxyAdmin).upgradeAndCall(ITransparentUpgradeableProxy(proxy), p.target, _data[id]); |
| 249 | if (paused) paused = false; // leaving the paused state goes through both doors like any upgrade |
| 250 | } else if (kind == Kind.SetGuardian) { |
| 251 | _setGuardian(p.target, p.value == 1); |
| 252 | } else if (kind == Kind.SetDelay) { |
| 253 | // forge-lint: disable-next-line(unsafe-typecast) |
| 254 | delayBlocks = uint64(p.value); // bounded by _checkDelay at propose time |
| 255 | } else { |
| 256 | pausedImpl = p.target; // code checked above: same codehash as at propose time, so not empty |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // ---------------------------------------------------------------- guardians |
| 261 | |
| 262 | /// @notice Veto a pending (cleared or not) proposal. Idempotent: vetoing an already vetoed proposal keeps the |
| 263 | /// status, counts the guardian (once per guardian) and emits the event again. A veto by the clearance |
| 264 | /// oracle (which is also a guardian) sets `oracleJudged[id]`. |
| 265 | function veto(uint256 id, bytes32 ruleHash) external onlyGuardian { |
| 266 | Proposal storage p = _proposals[id]; |
| 267 | Status s = p.status; |
| 268 | if (s == Status.None) revert UnknownProposal(id); |
| 269 | if (s == Status.Executed) revert AlreadyExecuted(id); |
| 270 | if (s == Status.Pending) p.status = Status.Vetoed; |
| 271 | if (msg.sender == clearanceOracle) oracleJudged[id] = true; |
| 272 | if (!hasVetoed[id][msg.sender]) { |
| 273 | hasVetoed[id][msg.sender] = true; |
| 274 | p.vetoCount += 1; |
| 275 | } |
| 276 | emit Vetoed(id, msg.sender, ruleHash, p.vetoCount); |
| 277 | } |
| 278 | |
| 279 | function attachReason(uint256 id, bytes32 reportHash) external onlyGuardian { |
| 280 | if (_proposals[id].status == Status.None) revert UnknownProposal(id); |
| 281 | emit ReasonAttached(id, msg.sender, reportHash); |
| 282 | } |
| 283 | |
| 284 | // ---------------------------------------------------------------- approver |
| 285 | |
| 286 | /// @notice Registration only: the contract never reads it. The clearance oracle reads it in strict mode. |
| 287 | function approveCodehash(bytes32 codehash) external { |
| 288 | if (msg.sender != approver) revert NotApprover(); |
| 289 | isApprovedCodehash[codehash] = true; |
| 290 | emit CodehashApproved(codehash, msg.sender); |
| 291 | } |
| 292 | |
| 293 | // ---------------------------------------------------------------- break glass |
| 294 | |
| 295 | function breakGlass() external { |
| 296 | if (msg.sender != owner && !isGuardian[msg.sender]) revert NotOwnerOrGuardian(); |
| 297 | if (paused) revert AlreadyPaused(); |
| 298 | paused = true; |
| 299 | address impl = pausedImpl; |
| 300 | emit BrokeGlass(msg.sender, impl); |
| 301 | ProxyAdmin(proxyAdmin).upgradeAndCall(ITransparentUpgradeableProxy(proxy), impl, ""); |
| 302 | } |
| 303 | |
| 304 | // ---------------------------------------------------------------- views |
| 305 | |
| 306 | function getProposal(uint256 id) external view returns (Proposal memory) { |
| 307 | return _proposals[id]; |
| 308 | } |
| 309 | |
| 310 | /// @notice The exact `data` of a proposal (its keccak256 is `dataHash`). |
| 311 | function getProposalData(uint256 id) external view returns (bytes memory) { |
| 312 | return _data[id]; |
| 313 | } |
| 314 | |
| 315 | /// @notice What a verdict about proposal `id` is bound to: |
| 316 | /// keccak256(abi.encode(uint8 kind, address target, uint256 value, bytes32 codehash, bytes32 dataHash, |
| 317 | /// uint64 proposedBlock)). A verdict signed for other content (another chain history, another |
| 318 | /// implementation, other init data) does not match. |
| 319 | function proposalDigest(uint256 id) public view returns (bytes32) { |
| 320 | Proposal storage p = _proposals[id]; |
| 321 | if (p.status == Status.None) revert UnknownProposal(id); |
| 322 | return keccak256(abi.encode(p.kind, p.target, p.value, p.codehash, p.dataHash, p.proposedBlock)); |
| 323 | } |
| 324 | |
| 325 | /// @notice True once the ProxyAdmin's owner has been handed to this contract. |
| 326 | function isWired() external view returns (bool) { |
| 327 | return ProxyAdmin(proxyAdmin).owner() == address(this); |
| 328 | } |
| 329 | |
| 330 | // ---------------------------------------------------------------- internal |
| 331 | |
| 332 | function _propose(Kind kind, address target, uint256 value, bytes32 codehash, bytes memory data) |
| 333 | internal |
| 334 | returns (uint256 id) |
| 335 | { |
| 336 | id = nextId++; |
| 337 | uint64 current = _blockNumber(); |
| 338 | uint64 ready = current + delayBlocks; |
| 339 | _proposals[id] = Proposal({ |
| 340 | kind: kind, |
| 341 | status: Status.Pending, |
| 342 | proposer: msg.sender, |
| 343 | target: target, |
| 344 | value: value, |
| 345 | codehash: codehash, |
| 346 | dataHash: keccak256(data), |
| 347 | proposedBlock: current, |
| 348 | readyBlock: ready, |
| 349 | vetoCount: 0 |
| 350 | }); |
| 351 | if (data.length != 0) _data[id] = data; |
| 352 | emit Proposed(id, uint8(kind), target, value, data, codehash, ready); |
| 353 | } |
| 354 | |
| 355 | function _requirePending(uint256 id) internal view { |
| 356 | Status s = _proposals[id].status; |
| 357 | if (s == Status.None) revert UnknownProposal(id); |
| 358 | if (s == Status.Vetoed) revert ProposalVetoed(id); |
| 359 | if (s == Status.Executed) revert AlreadyExecuted(id); |
| 360 | } |
| 361 | |
| 362 | function _setGuardian(address g, bool enabled) internal { |
| 363 | if (enabled && g == owner) revert RoleOverlap(); |
| 364 | if (isGuardian[g] == enabled) { |
| 365 | emit GuardianSet(g, enabled); // no-op, but observable |
| 366 | return; |
| 367 | } |
| 368 | uint256 count = enabled ? guardianCount + 1 : guardianCount - 1; |
| 369 | if (count < MIN_GUARDIANS || count > MAX_GUARDIANS) revert GuardianBounds(count); |
| 370 | isGuardian[g] = enabled; |
| 371 | guardianCount = count; |
| 372 | emit GuardianSet(g, enabled); |
| 373 | } |
| 374 | |
| 375 | /// @dev An upgrade or break-glass target must carry its own code: not empty, and not an EIP-7702 delegation |
| 376 | /// designator (whose codehash says nothing about the code it runs, and which its key can re-point). |
| 377 | function _requireContractCode(address target) internal view { |
| 378 | if (target.code.length == 0) revert NoCode(target); |
| 379 | if (_isDelegated(target)) revert DelegatedAccount(target); |
| 380 | } |
| 381 | |
| 382 | /// @dev True when `account`'s code starts with 0xef01: an EIP-7702 delegation designator (0xef0100 || address, |
| 383 | /// 23 bytes; early drafts of EIP-7702 exposed only 0xef01). EIP-3541 keeps 0xef out of the first byte of any |
| 384 | /// deployed contract, so no other code starts that way. Only short code is copied. |
| 385 | function _isDelegated(address account) internal view returns (bool) { |
| 386 | uint256 size = account.code.length; |
| 387 | if (size < 2 || size > 23) return false; |
| 388 | bytes memory code = account.code; |
| 389 | return code[0] == 0xef && code[1] == 0x01; |
| 390 | } |
| 391 | |
| 392 | function _blockNumber() internal view returns (uint64) { |
| 393 | // forge-lint: disable-next-line(unsafe-typecast) |
| 394 | return uint64(block.number); // block numbers fit in 64 bits for the lifetime of any chain |
| 395 | } |
| 396 | |
| 397 | function _checkDelay(uint64 value) internal pure { |
| 398 | if (value < MIN_DELAY_BLOCKS || value > MAX_DELAY_BLOCKS) revert InvalidDelay(value); |
| 399 | } |
| 400 | } |