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. |
| 5 | pragma solidity 0.8.37; |
| 6 | |
| 7 | import {Test} from "forge-std/Test.sol"; |
| 8 | import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; |
| 9 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; |
| 10 | import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; |
| 11 | |
| 12 | import {AirlockAdmin} from "../../src/AirlockAdmin.sol"; |
| 13 | import {DemoBTC} from "../../src/DemoBTC.sol"; |
| 14 | import {DemoBTCV2} from "../../src/DemoBTCV2.sol"; |
| 15 | import {PausedImpl} from "../../src/PausedImpl.sol"; |
| 16 | |
| 17 | contract 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. |
| 24 | contract DemoBTCV2Init is DemoBTC { |
| 25 | function initializeV2(address newOwner) external reinitializer(2) { |
| 26 | _transferOwnership(newOwner); |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | contract 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 | } |