test/AirlockReceiver.t.sol567 lines · 24400 bytes
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity 0.8.37; |
| 3 | |
| 4 | import {Test, Vm} from "forge-std/Test.sol"; |
| 5 | import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; |
| 6 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; |
| 7 | import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; |
| 8 | import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; |
| 9 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
| 10 | |
| 11 | import {AirlockAdmin} from "../src/AirlockAdmin.sol"; |
| 12 | import {DemoBTC} from "../src/DemoBTC.sol"; |
| 13 | import {DemoBTCV2} from "../src/DemoBTCV2.sol"; |
| 14 | import {PausedImpl} from "../src/PausedImpl.sol"; |
| 15 | import {EvilMintImpl} from "../src/attack/EvilMintImpl.sol"; |
| 16 | import {EvilDrainImpl} from "../src/attack/EvilDrainImpl.sol"; |
| 17 | import {AirlockReceiver} from "../src/cre/AirlockReceiver.sol"; |
| 18 | import {IReceiver} from "../src/cre/IReceiver.sol"; |
| 19 | |
| 20 | contract AirlockReceiverTest is Test { |
| 21 | uint64 constant DELAY = 30; |
| 22 | uint8 constant VETO = 0; |
| 23 | uint8 constant CLEAR = 1; |
| 24 | bytes32 constant R1 = keccak256("GUARD/R1_TOTAL_SUPPLY"); |
| 25 | bytes32 constant EVIDENCE = keccak256("evidence-json"); |
| 26 | |
| 27 | address deployer = makeAddr("deployer"); |
| 28 | address admin = makeAddr("admin"); |
| 29 | address emergency = makeAddr("emergencyGuardian"); |
| 30 | address approver = makeAddr("approver"); |
| 31 | address forwarder = makeAddr("forwarder"); |
| 32 | address h1 = makeAddr("holder1"); |
| 33 | Vm.Wallet attester; |
| 34 | Vm.Wallet impostor; |
| 35 | |
| 36 | DemoBTC v1; |
| 37 | DemoBTCV2 v2; |
| 38 | EvilMintImpl evilMint; |
| 39 | PausedImpl pausedImpl; |
| 40 | address proxy; |
| 41 | ProxyAdmin proxyAdmin; |
| 42 | AirlockAdmin aa; |
| 43 | AirlockReceiver rx; |
| 44 | |
| 45 | /// One signed verdict, as the workflow builds it (cre/veto/report.ts). |
| 46 | struct V { |
| 47 | uint8 action; |
| 48 | uint256 id; |
| 49 | bytes32 digest; |
| 50 | bytes32 rule; |
| 51 | bytes32 evidence; |
| 52 | uint64 until; |
| 53 | } |
| 54 | |
| 55 | function setUp() public { |
| 56 | vm.roll(100); |
| 57 | vm.warp(1_800_000_000); |
| 58 | attester = vm.createWallet("attester"); |
| 59 | impostor = vm.createWallet("impostor"); |
| 60 | |
| 61 | vm.startPrank(deployer); |
| 62 | v1 = new DemoBTC(); |
| 63 | address[] memory holders = new address[](1); |
| 64 | holders[0] = h1; |
| 65 | uint256[] memory amounts = new uint256[](1); |
| 66 | amounts[0] = 100e8; |
| 67 | proxy = address( |
| 68 | new TransparentUpgradeableProxy( |
| 69 | address(v1), admin, abi.encodeCall(DemoBTC.initialize, (admin, holders, amounts)) |
| 70 | ) |
| 71 | ); |
| 72 | proxyAdmin = ProxyAdmin(address(uint160(uint256(vm.load(proxy, ERC1967Utils.ADMIN_SLOT))))); |
| 73 | v2 = new DemoBTCV2(); |
| 74 | evilMint = new EvilMintImpl(); |
| 75 | pausedImpl = new PausedImpl(); |
| 76 | // The receiver needs AirlockAdmin's address and AirlockAdmin needs the receiver (clearance oracle with code, |
| 77 | // and a guardian): deploy the receiver first against AirlockAdmin's predicted address, exactly as |
| 78 | // script/DeployEth.s.sol does. |
| 79 | address predicted = vm.computeCreateAddress(deployer, vm.getNonce(deployer) + 1); |
| 80 | rx = new AirlockReceiver(predicted, forwarder, attester.addr, bytes32(0), address(0)); |
| 81 | address[] memory guardians = new address[](2); |
| 82 | guardians[0] = address(rx); |
| 83 | guardians[1] = emergency; |
| 84 | aa = new AirlockAdmin( |
| 85 | address(proxyAdmin), proxy, admin, guardians, approver, address(pausedImpl), DELAY, address(rx) |
| 86 | ); |
| 87 | vm.stopPrank(); |
| 88 | assertEq(address(aa), predicted, "predicted AirlockAdmin address"); |
| 89 | |
| 90 | vm.startPrank(admin); |
| 91 | proxyAdmin.transferOwnership(address(aa)); |
| 92 | DemoBTC(proxy).transferOwnership(address(aa)); |
| 93 | vm.stopPrank(); |
| 94 | } |
| 95 | |
| 96 | // ------------------------------------------------------------------ helpers |
| 97 | |
| 98 | function _propose(address impl) internal returns (uint256 id) { |
| 99 | vm.prank(admin); |
| 100 | id = aa.proposeUpgrade(impl, ""); |
| 101 | } |
| 102 | |
| 103 | function _verdict(uint8 action, uint256 id) internal view returns (V memory v) { |
| 104 | v.action = action; |
| 105 | v.id = id; |
| 106 | v.digest = aa.proposalDigest(id); |
| 107 | v.rule = action == VETO ? R1 : bytes32(0); |
| 108 | v.evidence = EVIDENCE; |
| 109 | v.until = uint64(vm.getBlockNumber() + 50); |
| 110 | } |
| 111 | |
| 112 | function _signWith(uint256 pk, AirlockReceiver r, V memory v) internal view returns (bytes memory) { |
| 113 | bytes32 h = r.verdictDigest(v.action, v.id, v.digest, v.rule, v.evidence, v.until); |
| 114 | (uint8 sv, bytes32 sr, bytes32 ss) = vm.sign(pk, h); |
| 115 | return abi.encodePacked(sr, ss, sv); |
| 116 | } |
| 117 | |
| 118 | function _report(V memory v, bytes memory sig) internal pure returns (bytes memory) { |
| 119 | return abi.encode(v.action, v.id, v.digest, v.rule, v.evidence, v.until, sig); |
| 120 | } |
| 121 | |
| 122 | function _signed(V memory v) internal view returns (bytes memory) { |
| 123 | return _report(v, _signWith(attester.privateKey, rx, v)); |
| 124 | } |
| 125 | |
| 126 | function _deliver(bytes memory rep) internal { |
| 127 | vm.prank(forwarder); |
| 128 | rx.onReport("", rep); |
| 129 | } |
| 130 | |
| 131 | function _metadata(bytes32 workflowId, address owner) internal pure returns (bytes memory) { |
| 132 | return abi.encodePacked(workflowId, bytes10("airlock-wf"), owner, bytes2(0x0001)); |
| 133 | } |
| 134 | |
| 135 | function _status(uint256 id) internal view returns (AirlockAdmin.Status) { |
| 136 | return aa.getProposal(id).status; |
| 137 | } |
| 138 | |
| 139 | // ------------------------------------------------------------------ EIP-712 shape (for the off-chain signer) |
| 140 | |
| 141 | function test_typehashAndDomain() public view { |
| 142 | assertEq( |
| 143 | rx.VERDICT_TYPEHASH(), |
| 144 | keccak256( |
| 145 | "Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)" |
| 146 | ) |
| 147 | ); |
| 148 | bytes32 domain = keccak256( |
| 149 | abi.encode( |
| 150 | keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), |
| 151 | keccak256("AirlockReceiver"), |
| 152 | keccak256("1"), |
| 153 | block.chainid, |
| 154 | address(rx) |
| 155 | ) |
| 156 | ); |
| 157 | assertEq(rx.domainSeparator(), domain); |
| 158 | bytes32 structHash = keccak256( |
| 159 | abi.encode( |
| 160 | rx.VERDICT_TYPEHASH(), |
| 161 | address(aa), |
| 162 | uint8(1), |
| 163 | uint256(7), |
| 164 | bytes32(uint256(0xd1)), |
| 165 | bytes32(0), |
| 166 | EVIDENCE, |
| 167 | uint64(1234) |
| 168 | ) |
| 169 | ); |
| 170 | assertEq( |
| 171 | rx.verdictDigest(1, 7, bytes32(uint256(0xd1)), bytes32(0), EVIDENCE, 1234), |
| 172 | keccak256(abi.encodePacked(hex"1901", domain, structHash)) |
| 173 | ); |
| 174 | assertEq(address(rx.airlockAdmin()), address(aa)); |
| 175 | assertEq(rx.attester(), attester.addr); |
| 176 | assertEq(rx.forwarder(), forwarder); |
| 177 | assertEq(aa.clearanceOracle(), address(rx)); |
| 178 | assertTrue(aa.isGuardian(address(rx))); |
| 179 | } |
| 180 | |
| 181 | // ------------------------------------------------------------------ veto |
| 182 | |
| 183 | function test_vetoReport_vetoesAndAttachesEvidence() public { |
| 184 | uint256 id = _propose(address(evilMint)); |
| 185 | V memory v = _verdict(VETO, id); |
| 186 | bytes memory rep = _signed(v); |
| 187 | |
| 188 | vm.expectEmit(true, true, false, true, address(rx)); |
| 189 | emit AirlockReceiver.Verdict(id, VETO, R1, EVIDENCE, bytes32(uint256(7))); |
| 190 | vm.expectEmit(true, true, false, true, address(aa)); |
| 191 | emit AirlockAdmin.Vetoed(id, address(rx), R1, 1); |
| 192 | vm.expectEmit(true, false, false, true, address(aa)); |
| 193 | emit AirlockAdmin.ReasonAttached(id, address(rx), EVIDENCE); |
| 194 | vm.prank(forwarder); |
| 195 | rx.onReport(_metadata(bytes32(uint256(7)), address(0xaaaa)), rep); |
| 196 | |
| 197 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Vetoed)); |
| 198 | assertTrue(aa.hasVetoed(id, address(rx))); |
| 199 | assertEq(aa.clearanceOf(id), bytes32(0)); |
| 200 | assertTrue(aa.oracleJudged(id), "a CRE veto is the oracle's verdict"); |
| 201 | |
| 202 | vm.roll(vm.getBlockNumber() + DELAY); |
| 203 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, id)); |
| 204 | aa.execute(id); |
| 205 | assertEq(DemoBTC(proxy).totalSupply(), 100e8, "malicious implementation never went live"); |
| 206 | } |
| 207 | |
| 208 | // ------------------------------------------------------------------ clear |
| 209 | |
| 210 | function test_clearReport_clearsThenExecutesAfterCooldown() public { |
| 211 | uint256 id = _propose(address(v2)); |
| 212 | V memory v = _verdict(CLEAR, id); |
| 213 | |
| 214 | vm.expectEmit(true, true, false, true, address(rx)); |
| 215 | emit AirlockReceiver.Verdict(id, CLEAR, bytes32(0), EVIDENCE, bytes32(0)); |
| 216 | vm.expectEmit(true, true, false, true, address(aa)); |
| 217 | emit AirlockAdmin.Cleared(id, address(rx), EVIDENCE); |
| 218 | assertFalse(aa.oracleJudged(id)); |
| 219 | _deliver(_signed(v)); |
| 220 | assertEq(aa.clearanceOf(id), EVIDENCE); |
| 221 | assertTrue(aa.oracleJudged(id), "a CRE clear is the oracle's verdict"); |
| 222 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Pending)); |
| 223 | |
| 224 | vm.roll(vm.getBlockNumber() + DELAY - 1); |
| 225 | vm.expectRevert(); // TooEarly: a clearance never shortens the cooldown |
| 226 | aa.execute(id); |
| 227 | vm.roll(vm.getBlockNumber() + 1); |
| 228 | aa.execute(id); |
| 229 | assertEq(DemoBTCV2(proxy).version(), "2"); |
| 230 | } |
| 231 | |
| 232 | function test_withoutClearReport_honestUpgradeWaits() public { |
| 233 | uint256 id = _propose(address(v2)); |
| 234 | vm.roll(vm.getBlockNumber() + DELAY * 10); |
| 235 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.NotCleared.selector, id)); |
| 236 | aa.execute(id); |
| 237 | } |
| 238 | |
| 239 | /// The emergency guardian's veto is not the oracle's verdict. |
| 240 | function test_emergencyVetoLeavesOracleJudgedUnset() public { |
| 241 | uint256 id = _propose(address(evilMint)); |
| 242 | vm.prank(emergency); |
| 243 | aa.veto(id, keccak256("manual")); |
| 244 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Vetoed)); |
| 245 | assertFalse(aa.oracleJudged(id)); |
| 246 | } |
| 247 | |
| 248 | /// What the sweeper relies on: a proposal the approver cleared by hand is still Pending with oracleJudged false, |
| 249 | /// so CRE judges it; a CRE Veto then wins over the manual clearance. |
| 250 | function test_approverClearedThenCreVeto_vetoWins() public { |
| 251 | EvilDrainImpl drain = new EvilDrainImpl(); |
| 252 | uint256 id = _propose(address(drain)); |
| 253 | bytes32 manual = keccak256("approver-review"); |
| 254 | vm.prank(approver); // a mistaken manual clearance |
| 255 | aa.clear(id, manual); |
| 256 | assertEq(aa.clearanceOf(id), manual); |
| 257 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Pending)); |
| 258 | assertFalse(aa.oracleJudged(id), "still to be judged by CRE"); |
| 259 | |
| 260 | _deliver(_signed(_verdict(VETO, id))); |
| 261 | assertTrue(aa.oracleJudged(id)); |
| 262 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Vetoed)); |
| 263 | vm.roll(vm.getBlockNumber() + DELAY); |
| 264 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, id)); |
| 265 | aa.execute(id); |
| 266 | } |
| 267 | |
| 268 | /// ...and a CRE Clear on an approver-cleared proposal marks it judged and keeps the first evidence hash. |
| 269 | function test_approverClearedThenCreClear_judgedFirstEvidenceKept() public { |
| 270 | uint256 id = _propose(address(v2)); |
| 271 | bytes32 manual = keccak256("approver-review"); |
| 272 | vm.prank(approver); |
| 273 | aa.clear(id, manual); |
| 274 | _deliver(_signed(_verdict(CLEAR, id))); |
| 275 | assertTrue(aa.oracleJudged(id)); |
| 276 | assertEq(aa.clearanceOf(id), manual); |
| 277 | vm.roll(vm.getBlockNumber() + DELAY); |
| 278 | aa.execute(id); |
| 279 | assertEq(DemoBTCV2(proxy).version(), "2"); |
| 280 | } |
| 281 | |
| 282 | /// The emergency guardian's veto beats the oracle's clearance. |
| 283 | function test_guardianVetoBeatsOracleClearance() public { |
| 284 | uint256 id = _propose(address(v2)); |
| 285 | _deliver(_signed(_verdict(CLEAR, id))); |
| 286 | vm.prank(emergency); |
| 287 | aa.veto(id, keccak256("manual")); |
| 288 | vm.roll(vm.getBlockNumber() + DELAY); |
| 289 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, id)); |
| 290 | aa.execute(id); |
| 291 | } |
| 292 | |
| 293 | /// A veto report landing after a clear report still wins (and a later clear report is refused). |
| 294 | function test_vetoReportAfterClearReport() public { |
| 295 | uint256 id = _propose(address(evilMint)); |
| 296 | bytes memory clearRep = _signed(_verdict(CLEAR, id)); |
| 297 | _deliver(clearRep); |
| 298 | _deliver(_signed(_verdict(VETO, id))); |
| 299 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Vetoed)); |
| 300 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, id)); |
| 301 | _deliver(clearRep); |
| 302 | vm.roll(vm.getBlockNumber() + DELAY); |
| 303 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, id)); |
| 304 | aa.execute(id); |
| 305 | } |
| 306 | |
| 307 | function test_clearReportWithRuleHashRejected() public { |
| 308 | uint256 id = _propose(address(v2)); |
| 309 | V memory v = _verdict(CLEAR, id); |
| 310 | v.rule = R1; |
| 311 | bytes memory rep = _signed(v); |
| 312 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.RuleOnClear.selector, R1)); |
| 313 | _deliver(rep); |
| 314 | assertEq(aa.clearanceOf(id), bytes32(0)); |
| 315 | } |
| 316 | |
| 317 | function test_unknownActionRejected() public { |
| 318 | uint256 id = _propose(address(v2)); |
| 319 | V memory v = _verdict(CLEAR, id); |
| 320 | v.action = 2; |
| 321 | bytes memory rep = _signed(v); |
| 322 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidAction.selector, uint8(2))); |
| 323 | _deliver(rep); |
| 324 | } |
| 325 | |
| 326 | function test_clearWithEmptyEvidenceRejected() public { |
| 327 | uint256 id = _propose(address(v2)); |
| 328 | V memory v = _verdict(CLEAR, id); |
| 329 | v.evidence = bytes32(0); |
| 330 | bytes memory rep = _signed(v); |
| 331 | vm.expectRevert(AirlockAdmin.EmptyEvidence.selector); |
| 332 | _deliver(rep); |
| 333 | } |
| 334 | |
| 335 | // ------------------------------------------------------------------ proposalDigest binding |
| 336 | |
| 337 | /// A verdict about other content is rejected even when the attester signed it: here a clearance computed for |
| 338 | /// an honest v2 proposal is pointed at a malicious proposal's id. |
| 339 | function test_digestBinding_otherContentRejected() public { |
| 340 | uint256 honest = _propose(address(v2)); |
| 341 | uint256 evil = _propose(address(evilMint)); |
| 342 | V memory v = _verdict(CLEAR, honest); |
| 343 | v.id = evil; // signed for (evil id, honest content) |
| 344 | bytes memory rep = _signed(v); |
| 345 | vm.expectRevert(AirlockReceiver.ProposalMismatch.selector); |
| 346 | _deliver(rep); |
| 347 | assertEq(aa.clearanceOf(evil), bytes32(0)); |
| 348 | assertEq(aa.clearanceOf(honest), bytes32(0)); |
| 349 | } |
| 350 | |
| 351 | /// The same id on another chain history (a reorg, a fork, a redeployment) carries other content. |
| 352 | function test_digestBinding_sameIdOtherHistoryRejected() public { |
| 353 | uint256 id = _propose(address(evilMint)); |
| 354 | V memory v = _verdict(CLEAR, id); |
| 355 | v.digest = keccak256( |
| 356 | abi.encode( |
| 357 | uint8(0), address(v2), uint256(0), address(v2).codehash, keccak256(""), uint64(vm.getBlockNumber()) |
| 358 | ) |
| 359 | ); |
| 360 | bytes memory rep = _signed(v); |
| 361 | vm.expectRevert(AirlockReceiver.ProposalMismatch.selector); |
| 362 | _deliver(rep); |
| 363 | v.digest = keccak256( |
| 364 | abi.encode( |
| 365 | uint8(0), |
| 366 | address(evilMint), |
| 367 | uint256(0), |
| 368 | address(evilMint).codehash, |
| 369 | keccak256(""), |
| 370 | uint64(vm.getBlockNumber() - 1) |
| 371 | ) |
| 372 | ); |
| 373 | rep = _signed(v); |
| 374 | vm.expectRevert(AirlockReceiver.ProposalMismatch.selector); |
| 375 | _deliver(rep); |
| 376 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Pending)); |
| 377 | } |
| 378 | |
| 379 | function test_digestBinding_unknownProposalRejected() public { |
| 380 | V memory v = V(CLEAR, 42, keccak256("x"), bytes32(0), EVIDENCE, uint64(vm.getBlockNumber() + 50)); |
| 381 | bytes memory rep = _signed(v); |
| 382 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.UnknownProposal.selector, 42)); |
| 383 | _deliver(rep); |
| 384 | } |
| 385 | |
| 386 | function testFuzz_digestBinding(bytes32 wrongDigest) public { |
| 387 | uint256 id = _propose(address(v2)); |
| 388 | vm.assume(wrongDigest != aa.proposalDigest(id)); |
| 389 | V memory v = _verdict(CLEAR, id); |
| 390 | v.digest = wrongDigest; |
| 391 | bytes memory rep = _signed(v); |
| 392 | vm.expectRevert(AirlockReceiver.ProposalMismatch.selector); |
| 393 | _deliver(rep); |
| 394 | } |
| 395 | |
| 396 | // ------------------------------------------------------------------ validUntilBlock |
| 397 | |
| 398 | function test_validUntilBlock_inclusive_thenExpired() public { |
| 399 | uint256 id = _propose(address(v2)); |
| 400 | V memory v = _verdict(CLEAR, id); |
| 401 | v.until = uint64(vm.getBlockNumber() + 5); |
| 402 | bytes memory rep = _signed(v); |
| 403 | |
| 404 | vm.roll(v.until + 1); |
| 405 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.ReportExpired.selector, v.until, uint256(v.until) + 1)); |
| 406 | _deliver(rep); |
| 407 | |
| 408 | vm.roll(v.until); // the last valid block |
| 409 | _deliver(rep); |
| 410 | assertEq(aa.clearanceOf(id), EVIDENCE); |
| 411 | } |
| 412 | |
| 413 | function test_validUntilBlock_isSigned() public { |
| 414 | uint256 id = _propose(address(evilMint)); |
| 415 | V memory v = _verdict(VETO, id); |
| 416 | v.until = uint64(vm.getBlockNumber() + 5); |
| 417 | bytes memory sig = _signWith(attester.privateKey, rx, v); |
| 418 | v.until = uint64(vm.getBlockNumber() + 500); // stretched after signing |
| 419 | vm.prank(forwarder); |
| 420 | vm.expectRevert(); // recovers some other address |
| 421 | rx.onReport("", _report(v, sig)); |
| 422 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Pending)); |
| 423 | } |
| 424 | |
| 425 | // ------------------------------------------------------------------ replay |
| 426 | |
| 427 | function test_replay_vetoIsHarmless() public { |
| 428 | uint256 id = _propose(address(evilMint)); |
| 429 | bytes memory rep = _signed(_verdict(VETO, id)); |
| 430 | _deliver(rep); |
| 431 | _deliver(rep); // same proposal, already vetoed: still vetoed, counted once |
| 432 | assertEq(aa.getProposal(id).vetoCount, 1); |
| 433 | } |
| 434 | |
| 435 | function test_replay_clearIsIdempotent_thenRefusedOnceExecuted() public { |
| 436 | uint256 id = _propose(address(v2)); |
| 437 | bytes memory rep = _signed(_verdict(CLEAR, id)); |
| 438 | _deliver(rep); |
| 439 | _deliver(rep); |
| 440 | assertEq(aa.clearanceOf(id), EVIDENCE); |
| 441 | vm.roll(vm.getBlockNumber() + DELAY); |
| 442 | aa.execute(id); |
| 443 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.AlreadyExecuted.selector, id)); |
| 444 | _deliver(rep); |
| 445 | } |
| 446 | |
| 447 | /// A report for one proposal never applies to another, even one with identical content in the same block. |
| 448 | function test_replay_otherIdSameContentRejected() public { |
| 449 | uint256 a = _propose(address(v2)); |
| 450 | uint256 b = _propose(address(v2)); |
| 451 | assertEq(aa.proposalDigest(a), aa.proposalDigest(b)); |
| 452 | V memory v = _verdict(CLEAR, a); |
| 453 | bytes memory sig = _signWith(attester.privateKey, rx, v); |
| 454 | v.id = b; |
| 455 | vm.prank(forwarder); |
| 456 | vm.expectRevert(); // the id is signed: recovers some other address |
| 457 | rx.onReport("", _report(v, sig)); |
| 458 | assertEq(aa.clearanceOf(b), bytes32(0)); |
| 459 | } |
| 460 | |
| 461 | // ------------------------------------------------------------------ authentication |
| 462 | |
| 463 | function test_rejectsWrongForwarder() public { |
| 464 | uint256 id = _propose(address(v2)); |
| 465 | bytes memory rep = _signed(_verdict(CLEAR, id)); |
| 466 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidSender.selector, address(this), forwarder)); |
| 467 | rx.onReport("", rep); |
| 468 | vm.prank(admin); |
| 469 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidSender.selector, admin, forwarder)); |
| 470 | rx.onReport("", rep); |
| 471 | assertEq(aa.clearanceOf(id), bytes32(0)); |
| 472 | } |
| 473 | |
| 474 | function test_rejectsImpostorSignature() public { |
| 475 | uint256 id = _propose(address(v2)); |
| 476 | V memory v = _verdict(CLEAR, id); |
| 477 | bytes memory rep = _report(v, _signWith(impostor.privateKey, rx, v)); |
| 478 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidAttestation.selector, impostor.addr)); |
| 479 | _deliver(rep); |
| 480 | } |
| 481 | |
| 482 | function test_signatureIsBoundToAction() public { |
| 483 | uint256 id = _propose(address(v2)); |
| 484 | V memory v = _verdict(VETO, id); |
| 485 | v.rule = bytes32(0); |
| 486 | bytes memory sig = _signWith(attester.privateKey, rx, v); // signed as a veto |
| 487 | v.action = CLEAR; |
| 488 | vm.prank(forwarder); |
| 489 | vm.expectRevert(); // recovers some other address |
| 490 | rx.onReport("", _report(v, sig)); |
| 491 | assertEq(aa.clearanceOf(id), bytes32(0)); |
| 492 | } |
| 493 | |
| 494 | function test_signatureIsBoundToReceiver() public { |
| 495 | AirlockReceiver rx2 = new AirlockReceiver(address(aa), forwarder, attester.addr, bytes32(0), address(0)); |
| 496 | uint256 id = _propose(address(v2)); |
| 497 | V memory v = _verdict(CLEAR, id); |
| 498 | bytes memory rep = _report(v, _signWith(attester.privateKey, rx2, v)); |
| 499 | vm.prank(forwarder); |
| 500 | vm.expectRevert(); // domain separator differs |
| 501 | rx.onReport("", rep); |
| 502 | } |
| 503 | |
| 504 | function test_malformedSignatureRejected() public { |
| 505 | uint256 id = _propose(address(v2)); |
| 506 | V memory v = _verdict(CLEAR, id); |
| 507 | vm.prank(forwarder); |
| 508 | vm.expectRevert(abi.encodeWithSelector(ECDSA.ECDSAInvalidSignatureLength.selector, uint256(3))); |
| 509 | rx.onReport("", _report(v, hex"010203")); |
| 510 | } |
| 511 | |
| 512 | function test_metadataCheckedWhenConfigured() public { |
| 513 | bytes32 wfId = keccak256("workflow"); |
| 514 | address wfOwner = makeAddr("workflowOwner"); |
| 515 | AirlockReceiver strict = new AirlockReceiver(address(aa), forwarder, attester.addr, wfId, wfOwner); |
| 516 | uint256 id = _propose(address(evilMint)); |
| 517 | V memory v = _verdict(VETO, id); |
| 518 | bytes memory rep = _report(v, _signWith(attester.privateKey, strict, v)); |
| 519 | |
| 520 | vm.startPrank(forwarder); |
| 521 | vm.expectRevert(AirlockReceiver.InvalidMetadata.selector); |
| 522 | strict.onReport("", rep); |
| 523 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidWorkflowId.selector, bytes32(uint256(1)), wfId)); |
| 524 | strict.onReport(_metadata(bytes32(uint256(1)), wfOwner), rep); |
| 525 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidWorkflowOwner.selector, address(0xbeef), wfOwner)); |
| 526 | strict.onReport(_metadata(wfId, address(0xbeef)), rep); |
| 527 | vm.stopPrank(); |
| 528 | // `strict` is not a guardian of `aa`, so a fully valid report gets as far as AirlockAdmin and stops there. |
| 529 | vm.expectRevert(AirlockAdmin.NotGuardian.selector); |
| 530 | vm.prank(forwarder); |
| 531 | strict.onReport(_metadata(wfId, wfOwner), rep); |
| 532 | // nor is it the clearance oracle |
| 533 | V memory c = _verdict(CLEAR, id); |
| 534 | bytes memory clearRep = _report(c, _signWith(attester.privateKey, strict, c)); |
| 535 | vm.expectRevert(AirlockAdmin.NotClearer.selector); |
| 536 | vm.prank(forwarder); |
| 537 | strict.onReport(_metadata(wfId, wfOwner), clearRep); |
| 538 | } |
| 539 | |
| 540 | function test_supportsInterface() public view { |
| 541 | assertTrue(rx.supportsInterface(type(IReceiver).interfaceId)); |
| 542 | assertTrue(rx.supportsInterface(type(IERC165).interfaceId)); |
| 543 | assertFalse(rx.supportsInterface(0xffffffff)); |
| 544 | } |
| 545 | |
| 546 | function test_constructorRejectsZero() public { |
| 547 | vm.expectRevert(AirlockReceiver.ZeroAddress.selector); |
| 548 | new AirlockReceiver(address(0), forwarder, attester.addr, bytes32(0), address(0)); |
| 549 | vm.expectRevert(AirlockReceiver.ZeroAddress.selector); |
| 550 | new AirlockReceiver(address(aa), address(0), attester.addr, bytes32(0), address(0)); |
| 551 | vm.expectRevert(AirlockReceiver.ZeroAddress.selector); |
| 552 | new AirlockReceiver(address(aa), forwarder, address(0), bytes32(0), address(0)); |
| 553 | } |
| 554 | |
| 555 | /// Only the attester's signatures carry a verdict, for either action. |
| 556 | function testFuzz_impostorSignatureRejected(uint256 pk, bool clear) public { |
| 557 | pk = bound(pk, 1, 115792089237316195423570985008687907852837564279074904382605163141518161494336); |
| 558 | vm.assume(pk != attester.privateKey); |
| 559 | uint256 id = _propose(address(evilMint)); |
| 560 | V memory v = _verdict(clear ? CLEAR : VETO, id); |
| 561 | bytes memory rep = _report(v, _signWith(pk, rx, v)); |
| 562 | vm.expectRevert(abi.encodeWithSelector(AirlockReceiver.InvalidAttestation.selector, vm.addr(pk))); |
| 563 | _deliver(rep); |
| 564 | assertEq(uint8(_status(id)), uint8(AirlockAdmin.Status.Pending)); |
| 565 | assertEq(aa.clearanceOf(id), bytes32(0)); |
| 566 | } |
| 567 | } |