Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / src / AirlockAdmin.sol
src/AirlockAdmin.sol400 lines · 18681 bytes
1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.37;
3
4import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
5import {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.
26contract 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}