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 / test / known-issues / KnownIssues.t.sol
test/known-issues/KnownIssues.t.sol244 lines · 10625 bytes
1// SPDX-License-Identifier: MIT
2// Known limitations of AirlockAdmin. Each test asserts the CURRENT behaviour so the limitation stays documented and
3// reproducible. When a later version removes one, its test fails: replace it with a test of the protective
4// behaviour. These are testnet demo contracts, and this file is where their known limitations are recorded.
5pragma solidity 0.8.37;
6
7import {Test} from "forge-std/Test.sol";
8import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
9import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
10import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
11
12import {AirlockAdmin} from "../../src/AirlockAdmin.sol";
13import {DemoBTC} from "../../src/DemoBTC.sol";
14import {DemoBTCV2} from "../../src/DemoBTCV2.sol";
15import {PausedImpl} from "../../src/PausedImpl.sol";
16
17contract DemoBTCV3 is DemoBTC {
18 function version() external pure returns (string memory) {
19 return "3";
20 }
21}
22
23/// Reinitializer-style impl: its codehash says nothing about who the init args name.
24contract DemoBTCV2Init is DemoBTC {
25 function initializeV2(address newOwner) external reinitializer(2) {
26 _transferOwnership(newOwner);
27 }
28}
29
30contract KnownIssuesTest is Test {
31 uint64 constant DELAY = 30;
32 bytes32 constant EVIDENCE = keccak256("evidence-json");
33 address deployer = makeAddr("deployer");
34 address admin = makeAddr("admin");
35 address s1 = makeAddr("guardian1");
36 address s2 = makeAddr("guardian2");
37 address s3 = makeAddr("guardian3");
38 address approver = makeAddr("approver");
39 address oracle = makeAddr("clearanceOracle");
40 address h1 = makeAddr("holder1");
41 address h2 = makeAddr("holder2");
42 address stranger = makeAddr("stranger");
43
44 DemoBTC v1;
45 DemoBTCV2 v2;
46 DemoBTCV3 v3;
47 PausedImpl pausedImpl;
48 address proxy;
49 ProxyAdmin proxyAdmin;
50 AirlockAdmin aa;
51 DemoBTC token;
52
53 function setUp() public {
54 vm.roll(100);
55 vm.etch(oracle, hex"00");
56 vm.startPrank(deployer);
57 v1 = new DemoBTC();
58 address[] memory holders = new address[](2);
59 holders[0] = h1;
60 holders[1] = h2;
61 uint256[] memory amounts = new uint256[](2);
62 amounts[0] = 100e8;
63 amounts[1] = 50e8;
64 proxy = address(
65 new TransparentUpgradeableProxy(
66 address(v1), admin, abi.encodeCall(DemoBTC.initialize, (admin, holders, amounts))
67 )
68 );
69 proxyAdmin = ProxyAdmin(address(uint160(uint256(vm.load(proxy, ERC1967Utils.ADMIN_SLOT)))));
70 v2 = new DemoBTCV2();
71 v3 = new DemoBTCV3();
72 pausedImpl = new PausedImpl();
73 address[] memory g = new address[](3);
74 g[0] = s1;
75 g[1] = s2;
76 g[2] = s3;
77 aa = new AirlockAdmin(address(proxyAdmin), proxy, admin, g, approver, address(pausedImpl), DELAY, oracle);
78 vm.stopPrank();
79 token = DemoBTC(proxy);
80 vm.startPrank(admin);
81 proxyAdmin.transferOwnership(address(aa));
82 token.transferOwnership(address(aa));
83 vm.stopPrank();
84 }
85
86 function _impl() internal view returns (address) {
87 return address(uint160(uint256(vm.load(proxy, ERC1967Utils.IMPLEMENTATION_SLOT))));
88 }
89
90 function _proposeCleared(address impl) internal returns (uint256 id) {
91 vm.prank(admin);
92 id = aa.proposeUpgrade(impl, "");
93 vm.prank(oracle);
94 aa.clear(id, EVIDENCE);
95 }
96
97 /// A matured, cleared, never-executed Upgrade proposal lets ANYONE undo breakGlass in the same block.
98 function test_knownIssue_staleProposalUndoesBreakGlassInstantly() public {
99 uint256 stale = _proposeCleared(address(v1)); // benign, the oracle clears it, nobody executes
100 vm.roll(vm.getBlockNumber() + 5000); // long matured
101
102 vm.prank(s1);
103 aa.breakGlass(); // incident: guardians freeze the token
104 assertTrue(aa.paused());
105
106 vm.prank(stranger); // same block, no key at all
107 aa.execute(stale);
108 assertFalse(aa.paused());
109 assertEq(_impl(), address(v1));
110 vm.prank(h1);
111 assertTrue(token.transfer(h2, 1e8)); // unfrozen, zero blocks of delay
112 }
113
114 /// Proposals and clearances never expire and execute order is free: anyone can roll back to an older impl.
115 function test_knownIssue_staleProposalRollback() public {
116 uint256 pOld = _proposeCleared(address(v2));
117 uint256 pNew = _proposeCleared(address(v3));
118 vm.roll(vm.getBlockNumber() + DELAY);
119 aa.execute(pNew);
120 assertEq(_impl(), address(v3));
121 vm.roll(vm.getBlockNumber() + 100_000);
122 vm.prank(stranger);
123 aa.execute(pOld); // downgrade, months later, by anyone
124 assertEq(_impl(), address(v2));
125 }
126
127 /// One rogue guardian (any one of the guardian keys): pause, then veto every exit forever. A clearance does
128 /// not help: a veto always wins.
129 function test_knownIssue_singleGuardianPermanentFreeze() public {
130 vm.prank(s3);
131 aa.breakGlass();
132 for (uint256 round = 0; round < 5; round++) {
133 vm.prank(admin);
134 uint256 unpause = aa.proposeUpgrade(address(v1), "");
135 vm.prank(admin);
136 uint256 removeS3 = aa.proposeSetGuardian(s3, false);
137 vm.startPrank(approver);
138 aa.clear(unpause, EVIDENCE);
139 aa.clear(removeS3, EVIDENCE);
140 vm.stopPrank();
141 vm.startPrank(s3);
142 aa.veto(unpause, bytes32(0));
143 aa.veto(removeS3, bytes32(0));
144 vm.stopPrank();
145 vm.roll(vm.getBlockNumber() + DELAY);
146 vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, unpause));
147 aa.execute(unpause);
148 vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, removeS3));
149 aa.execute(removeS3);
150 }
151 assertTrue(aa.paused());
152 assertTrue(aa.isGuardian(s3));
153 vm.prank(h1);
154 vm.expectRevert(PausedImpl.TokenPaused.selector);
155 token.transfer(h2, 1);
156 }
157
158 /// Stolen owner key (immutable, cannot be rotated): re-pause right after every legitimate unpause.
159 function test_knownIssue_stolenOwnerRepausesForever() public {
160 for (uint256 round = 0; round < 3; round++) {
161 vm.prank(admin); // attacker copy of the key
162 aa.breakGlass();
163 uint256 id = _proposeCleared(address(v1)); // team copy of the key, cleared by the oracle
164 vm.roll(vm.getBlockNumber() + DELAY);
165 aa.execute(id);
166 assertFalse(aa.paused());
167 }
168 vm.prank(admin);
169 aa.breakGlass(); // same block as the last unpause
170 assertTrue(aa.paused());
171 }
172
173 /// The config rule treats raising the delay as "not weakening", so it is cleared; the attacker then makes every
174 /// legitimate unpause wait MAX_DELAY_BLOCKS while re-pausing at will.
175 function test_knownIssue_raiseDelayThenPause() public {
176 vm.prank(admin);
177 uint256 id = aa.proposeSetDelay(50_400);
178 vm.prank(oracle);
179 aa.clear(id, EVIDENCE);
180 vm.roll(vm.getBlockNumber() + DELAY);
181 aa.execute(id);
182 vm.prank(admin);
183 aa.breakGlass();
184 vm.prank(admin);
185 uint256 unpause = aa.proposeUpgrade(address(v1), "");
186 assertEq(aa.getProposal(unpause).readyBlock, vm.getBlockNumber() + 50_400);
187 }
188
189 /// The unpause is an upgrade, so it needs a clearance too: while the oracle is down and the approver is
190 /// unreachable, a paused token stays paused however long everyone waits.
191 function test_knownIssue_unpauseNeedsAClearer() public {
192 vm.prank(s1);
193 aa.breakGlass();
194 vm.prank(admin);
195 uint256 unpause = aa.proposeUpgrade(address(v1), "");
196 vm.roll(vm.getBlockNumber() + 100_000);
197 vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.NotCleared.selector, unpause));
198 aa.execute(unpause);
199 assertTrue(aa.paused());
200 }
201
202 /// isApprovedCodehash is a registry only, and a clearance is per proposal: the contract checks only that the
203 /// target's code is still the code that was proposed, never the registry or the init data. Init data runs with
204 /// msg.sender = ProxyAdmin, so reinitializer args are whatever the owner proposed; only the clearer's judgement
205 /// (the workflow's init-data rule, or the approver) stops it.
206 function test_knownIssue_clearedReinitializerHandsOwnership() public {
207 DemoBTCV2Init vi = new DemoBTCV2Init();
208 vm.prank(admin);
209 uint256 id = aa.proposeUpgrade(address(vi), abi.encodeCall(DemoBTCV2Init.initializeV2, (stranger)));
210 vm.prank(approver); // a mistaken manual clearance
211 aa.clear(id, EVIDENCE);
212 vm.roll(vm.getBlockNumber() + DELAY);
213 assertFalse(aa.isApprovedCodehash(address(vi).codehash));
214 aa.execute(id); // no on-chain check of the registry or the data
215 assertEq(token.owner(), stranger);
216 }
217
218 /// oracleJudged tells the sweeper what CRE still has to judge; it is not a third door. A manual clearance alone
219 /// lets a proposal run after the cooldown when no oracle verdict lands in time (the approver is the fallback
220 /// for an unavailable oracle). The sweep window: the sweeper scans only the newest `sweepWindow` ids (20, at
221 /// most 30), so an approver-cleared proposal whose Proposed log the trigger missed, and that falls below the
222 /// window before a sweep reaches it (`sweepWindow` newer proposals), is never judged by CRE and executes on the
223 /// approver's clearance alone. The approver's manual clearance is an override by design.
224 function test_knownIssue_approverClearanceRunsWithoutOracleVerdict() public {
225 vm.prank(admin);
226 uint256 id = aa.proposeUpgrade(address(v2), "");
227 vm.prank(approver);
228 aa.clear(id, EVIDENCE);
229 vm.roll(vm.getBlockNumber() + DELAY);
230 assertFalse(aa.oracleJudged(id));
231 vm.prank(stranger);
232 aa.execute(id);
233 assertEq(_impl(), address(v2));
234 }
235
236 /// ProxyAdmin ownership can never leave AirlockAdmin: no migration path.
237 function test_knownIssue_noMigrationPath() public view {
238 assertEq(proxyAdmin.owner(), address(aa));
239 // AirlockAdmin exposes no call to ProxyAdmin.transferOwnership; owner, approver and clearanceOracle are
240 // immutable.
241 assertEq(aa.owner(), admin);
242 assertEq(aa.clearanceOracle(), oracle);
243 }
244}