test/DeployEth.t.sol351 lines · 15579 bytes
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity 0.8.37; |
| 3 | |
| 4 | import {Test, Vm} from "forge-std/Test.sol"; |
| 5 | import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; |
| 6 | import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; |
| 7 | |
| 8 | import {DeployEth} from "../script/DeployEth.s.sol"; |
| 9 | import {AirlockAdmin} from "../src/AirlockAdmin.sol"; |
| 10 | import {DemoBTC} from "../src/DemoBTC.sol"; |
| 11 | import {DemoBTCV2} from "../src/DemoBTCV2.sol"; |
| 12 | import {EvilDrainImpl} from "../src/attack/EvilDrainImpl.sol"; |
| 13 | import {AirlockReceiver} from "../src/cre/AirlockReceiver.sol"; |
| 14 | |
| 15 | /// script/DeployEth.s.sol run in-process: wiring, role refusal (attester included), the mock-forwarder opt-in, the |
| 16 | /// JSON it writes, and one clear and one veto through the deployed receiver. The same script against an anvil fork |
| 17 | /// of Sepolia is the deployment rehearsal. |
| 18 | contract DeployEthTest is Test { |
| 19 | address constant MOCK_FORWARDER = 0x15fC6ae953E024d975e77382eEeC56A9101f9F88; |
| 20 | bytes32 constant R1 = keccak256("GUARD/R1_TOTAL_SUPPLY"); |
| 21 | bytes32 constant WORKFLOW_ID = keccak256("airlock-workflow"); |
| 22 | |
| 23 | DeployEth script; |
| 24 | address owner; |
| 25 | uint256 ownerPk; |
| 26 | Vm.Wallet attester; |
| 27 | address approver = makeAddr("approver"); |
| 28 | address emergency = makeAddr("emergencyGuardian"); |
| 29 | |
| 30 | function setUp() public { |
| 31 | vm.roll(1_000); |
| 32 | (owner, ownerPk) = makeAddrAndKey("owner"); |
| 33 | attester = vm.createWallet("attester"); |
| 34 | vm.etch(MOCK_FORWARDER, hex"00"); // the script refuses a forwarder without code |
| 35 | script = new DeployEth(); |
| 36 | } |
| 37 | |
| 38 | function _cfg() internal view returns (DeployEth.Cfg memory c) { |
| 39 | c.pk = ownerPk; |
| 40 | c.owner = owner; |
| 41 | c.attester = attester.addr; |
| 42 | c.forwarder = MOCK_FORWARDER; |
| 43 | c.approver = approver; |
| 44 | c.emergency = emergency; |
| 45 | c.delay = 25; |
| 46 | c.allowMockForwarder = true; // the simulation demo: the attester alone authenticates verdicts |
| 47 | (c.holders, c.amounts) = script.demoHolders(); |
| 48 | } |
| 49 | |
| 50 | function test_deploy_wiresTheAirlock() public { |
| 51 | uint64 n0 = vm.getNonce(owner); |
| 52 | DeployEth.Out memory o = script.deploy(_cfg()); |
| 53 | assertEq(vm.getNonce(owner), n0 + 13, "13 transactions"); |
| 54 | AirlockAdmin a = AirlockAdmin(o.airlockAdmin); |
| 55 | AirlockReceiver r = AirlockReceiver(o.receiver); |
| 56 | |
| 57 | assertEq(a.owner(), owner); |
| 58 | assertEq(a.approver(), approver); |
| 59 | assertEq(a.clearanceOracle(), o.receiver); |
| 60 | assertEq(a.guardianCount(), 2); |
| 61 | assertTrue(a.isGuardian(o.receiver) && a.isGuardian(emergency)); |
| 62 | assertFalse(a.isGuardian(owner)); |
| 63 | assertEq(a.pausedImpl(), o.paused); |
| 64 | assertEq(a.delayBlocks(), 25); |
| 65 | assertEq(a.proxy(), o.proxy); |
| 66 | assertEq(a.proxyAdmin(), o.proxyAdmin); |
| 67 | assertTrue(a.isWired()); |
| 68 | assertEq(ProxyAdmin(o.proxyAdmin).owner(), o.airlockAdmin); |
| 69 | assertEq(address(uint160(uint256(vm.load(o.proxy, ERC1967Utils.ADMIN_SLOT)))), o.proxyAdmin); |
| 70 | assertEq(address(uint160(uint256(vm.load(o.proxy, ERC1967Utils.IMPLEMENTATION_SLOT)))), o.v1); |
| 71 | |
| 72 | assertEq(address(r.airlockAdmin()), o.airlockAdmin); |
| 73 | assertEq(r.forwarder(), MOCK_FORWARDER); |
| 74 | assertEq(r.attester(), attester.addr); |
| 75 | assertEq(r.expectedWorkflowId(), bytes32(0)); |
| 76 | assertEq(r.expectedWorkflowOwner(), address(0)); |
| 77 | |
| 78 | DemoBTC t = DemoBTC(o.proxy); |
| 79 | assertEq(t.owner(), o.airlockAdmin); |
| 80 | assertEq(t.totalSupply(), 175e8); |
| 81 | (address[] memory holders,) = script.demoHolders(); |
| 82 | assertEq(t.balanceOf(holders[0]), 100e8); |
| 83 | address[9] memory impls = |
| 84 | [o.v1, o.v2, o.evilMint, o.evilOwner, o.evilFee, o.evilBaseFee, o.evilDrain, o.paused, o.receiver]; |
| 85 | for (uint256 i = 0; i < impls.length; i++) { |
| 86 | assertGt(impls[i].code.length, 0); |
| 87 | for (uint256 k = 0; k < i; k++) { |
| 88 | assertTrue(impls[i] != impls[k], "distinct contracts"); |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// The deployed drain attack is a real drain behind the proxy: a stranger moves a holder's tokens. |
| 94 | function test_deploy_evilDrainDrainsBehindTheProxy() public { |
| 95 | DeployEth.Out memory o = script.deploy(_cfg()); |
| 96 | (address[] memory holders,) = script.demoHolders(); |
| 97 | vm.store(o.proxy, ERC1967Utils.IMPLEMENTATION_SLOT, bytes32(uint256(uint160(o.evilDrain)))); |
| 98 | address thief = makeAddr("thief"); |
| 99 | vm.prank(thief); |
| 100 | EvilDrainImpl(o.proxy).rescue(holders[0], thief, 100e8); |
| 101 | assertEq(DemoBTC(o.proxy).balanceOf(holders[0]), 0); |
| 102 | assertEq(DemoBTC(o.proxy).balanceOf(thief), 100e8); |
| 103 | assertEq(DemoBTC(o.proxy).totalSupply(), 175e8); |
| 104 | } |
| 105 | |
| 106 | /// The deployed stack works end to end: an attester-signed Clear lets v2 through after the cooldown, an |
| 107 | /// attester-signed Veto stops the basefee-evasion attack for good. |
| 108 | function test_deploy_clearAndVetoThroughTheReceiver() public { |
| 109 | DeployEth.Out memory o = script.deploy(_cfg()); |
| 110 | AirlockAdmin a = AirlockAdmin(o.airlockAdmin); |
| 111 | AirlockReceiver r = AirlockReceiver(o.receiver); |
| 112 | |
| 113 | vm.startPrank(owner); |
| 114 | uint256 honest = a.proposeUpgrade(o.v2, ""); |
| 115 | uint256 evil = a.proposeUpgrade(o.evilBaseFee, ""); |
| 116 | vm.stopPrank(); |
| 117 | |
| 118 | _deliver(r, 1, honest, a.proposalDigest(honest), bytes32(0)); |
| 119 | _deliver(r, 0, evil, a.proposalDigest(evil), R1); |
| 120 | assertEq(a.clearanceOf(honest), keccak256("evidence")); |
| 121 | assertEq(uint8(a.getProposal(evil).status), uint8(AirlockAdmin.Status.Vetoed)); |
| 122 | |
| 123 | vm.roll(vm.getBlockNumber() + 25); |
| 124 | a.execute(honest); |
| 125 | assertEq(DemoBTCV2(o.proxy).version(), "2"); |
| 126 | vm.expectRevert(abi.encodeWithSelector(AirlockAdmin.ProposalVetoed.selector, evil)); |
| 127 | a.execute(evil); |
| 128 | } |
| 129 | |
| 130 | function _deliver(AirlockReceiver r, uint8 action, uint256 id, bytes32 digest, bytes32 rule) internal { |
| 131 | bytes32 ev = keccak256("evidence"); |
| 132 | uint64 until = uint64(vm.getBlockNumber() + 100); |
| 133 | (uint8 v, bytes32 sr, bytes32 ss) = |
| 134 | vm.sign(attester.privateKey, r.verdictDigest(action, id, digest, rule, ev, until)); |
| 135 | vm.prank(MOCK_FORWARDER); |
| 136 | r.onReport("", abi.encode(action, id, digest, rule, ev, until, abi.encodePacked(sr, ss, v))); |
| 137 | } |
| 138 | |
| 139 | function test_deploy_refusesMissingOrOverlappingRoles() public { |
| 140 | DeployEth.Cfg memory c = _cfg(); |
| 141 | c.approver = address(0); |
| 142 | vm.expectRevert(bytes("APPROVER_ADDR is required")); |
| 143 | script.deploy(c); |
| 144 | |
| 145 | c = _cfg(); |
| 146 | c.emergency = address(0); |
| 147 | vm.expectRevert(bytes("EMERGENCY_GUARDIAN_ADDR is required")); |
| 148 | script.deploy(c); |
| 149 | |
| 150 | c = _cfg(); |
| 151 | c.approver = owner; |
| 152 | vm.expectRevert(bytes("APPROVER_ADDR must differ from the owner (deployer)")); |
| 153 | script.deploy(c); |
| 154 | |
| 155 | c = _cfg(); |
| 156 | c.emergency = owner; |
| 157 | vm.expectRevert(bytes("EMERGENCY_GUARDIAN_ADDR must differ from the owner (deployer)")); |
| 158 | script.deploy(c); |
| 159 | |
| 160 | c = _cfg(); |
| 161 | c.emergency = approver; |
| 162 | vm.expectRevert(bytes("APPROVER_ADDR and EMERGENCY_GUARDIAN_ADDR must differ")); |
| 163 | script.deploy(c); |
| 164 | |
| 165 | c = _cfg(); |
| 166 | c.forwarder = makeAddr("noCode"); |
| 167 | vm.expectRevert(bytes("forwarder has no code on this chain")); |
| 168 | script.deploy(c); |
| 169 | |
| 170 | assertEq(vm.getNonce(owner), 0, "nothing was deployed"); |
| 171 | } |
| 172 | |
| 173 | /// The attester's signature is what authenticates a verdict behind the mock forwarder, so it may not be the |
| 174 | /// owner's, the approver's or the emergency guardian's key. |
| 175 | function test_deploy_refusesMissingOrOverlappingAttester() public { |
| 176 | DeployEth.Cfg memory c = _cfg(); |
| 177 | c.attester = address(0); |
| 178 | vm.expectRevert(bytes("ATTESTER_ADDR is required")); |
| 179 | script.deploy(c); |
| 180 | |
| 181 | c = _cfg(); |
| 182 | c.attester = owner; |
| 183 | vm.expectRevert(bytes("ATTESTER_ADDR must differ from the owner (deployer)")); |
| 184 | script.deploy(c); |
| 185 | |
| 186 | c = _cfg(); |
| 187 | c.attester = approver; |
| 188 | vm.expectRevert(bytes("ATTESTER_ADDR must differ from APPROVER_ADDR")); |
| 189 | script.deploy(c); |
| 190 | |
| 191 | c = _cfg(); |
| 192 | c.attester = emergency; |
| 193 | vm.expectRevert(bytes("ATTESTER_ADDR must differ from EMERGENCY_GUARDIAN_ADDR")); |
| 194 | script.deploy(c); |
| 195 | |
| 196 | assertEq(vm.getNonce(owner), 0, "nothing was deployed"); |
| 197 | } |
| 198 | |
| 199 | function testFuzz_checkRoles_attesterMustBeItsOwnKey(address a, uint8 pick) public { |
| 200 | address[3] memory others = [owner, approver, emergency]; |
| 201 | address att = pick % 4 == 3 ? a : others[pick % 4]; |
| 202 | bool distinct = att != address(0) && att != owner && att != approver && att != emergency; |
| 203 | if (!distinct) vm.expectRevert(); |
| 204 | script.checkRoles(owner, approver, emergency, att); |
| 205 | } |
| 206 | |
| 207 | /// The Sepolia MockKeystoneForwarder is permissionless: the script refuses it unless the run opts in |
| 208 | /// (ALLOW_MOCK_FORWARDER=1) or names the workflow (WORKFLOW_ID and WORKFLOW_OWNER). Other forwarders pass. |
| 209 | function test_deploy_mockForwarderNeedsAnExplicitOptIn() public { |
| 210 | string memory refused = "mock forwarder refused: set ALLOW_MOCK_FORWARDER=1, or WORKFLOW_ID and WORKFLOW_OWNER"; |
| 211 | address wfOwner = makeAddr("workflowOwner"); |
| 212 | DeployEth.Cfg memory c = _cfg(); |
| 213 | c.allowMockForwarder = false; |
| 214 | vm.expectRevert(bytes(refused)); |
| 215 | script.deploy(c); |
| 216 | c.workflowId = WORKFLOW_ID; // the id alone is not enough |
| 217 | vm.expectRevert(bytes(refused)); |
| 218 | script.deploy(c); |
| 219 | c.workflowId = bytes32(0); |
| 220 | c.workflowOwner = wfOwner; // nor the owner alone |
| 221 | vm.expectRevert(bytes(refused)); |
| 222 | script.deploy(c); |
| 223 | assertEq(vm.getNonce(owner), 0, "nothing was deployed"); |
| 224 | |
| 225 | uint256 snap = vm.snapshotState(); |
| 226 | c.workflowId = WORKFLOW_ID; // both: the receiver checks them |
| 227 | DeployEth.Out memory o = script.deploy(c); |
| 228 | assertEq(AirlockReceiver(o.receiver).expectedWorkflowId(), WORKFLOW_ID); |
| 229 | assertEq(AirlockReceiver(o.receiver).expectedWorkflowOwner(), wfOwner); |
| 230 | vm.revertToState(snap); |
| 231 | |
| 232 | c = _cfg(); // the explicit opt-in |
| 233 | o = script.deploy(c); |
| 234 | assertEq(AirlockReceiver(o.receiver).forwarder(), MOCK_FORWARDER); |
| 235 | assertEq(AirlockReceiver(o.receiver).expectedWorkflowId(), bytes32(0)); |
| 236 | vm.revertToState(snap); |
| 237 | |
| 238 | c = _cfg(); // any other forwarder (e.g. the production KeystoneForwarder) needs no opt-in |
| 239 | c.allowMockForwarder = false; |
| 240 | c.forwarder = makeAddr("keystoneForwarder"); |
| 241 | vm.etch(c.forwarder, hex"00"); |
| 242 | o = script.deploy(c); |
| 243 | assertEq(AirlockReceiver(o.receiver).forwarder(), c.forwarder); |
| 244 | } |
| 245 | |
| 246 | function test_checkForwarder_passes() public { |
| 247 | address wfOwner = makeAddr("workflowOwner"); |
| 248 | script.checkForwarder(MOCK_FORWARDER, bytes32(0), address(0), true); |
| 249 | script.checkForwarder(MOCK_FORWARDER, WORKFLOW_ID, wfOwner, false); |
| 250 | script.checkForwarder(makeAddr("keystoneForwarder"), bytes32(0), address(0), false); |
| 251 | } |
| 252 | |
| 253 | /// `run()` reads the environment. This is the only test that touches environment variables. |
| 254 | function test_run_fromEnv_refusesBadRoles_thenWritesJson() public { |
| 255 | string memory out = "cache/deploy-eth-test.json"; |
| 256 | vm.setEnv("DEPLOYER_PK", vm.toString(ownerPk)); |
| 257 | vm.setEnv("ATTESTER_ADDR", vm.toString(attester.addr)); |
| 258 | vm.setEnv("FORWARDER", vm.toString(MOCK_FORWARDER)); |
| 259 | vm.setEnv("DELAY_BLOCKS", "25"); |
| 260 | vm.setEnv("WORKFLOW_ID", vm.toString(bytes32(0))); |
| 261 | vm.setEnv("WORKFLOW_OWNER", vm.toString(address(0))); |
| 262 | vm.setEnv("OUT_JSON", out); |
| 263 | vm.setEnv("ALLOW_MOCK_FORWARDER", "1"); |
| 264 | |
| 265 | // missing (empty) approver / emergency guardian |
| 266 | vm.setEnv("APPROVER_ADDR", ""); |
| 267 | vm.setEnv("EMERGENCY_GUARDIAN_ADDR", vm.toString(emergency)); |
| 268 | vm.expectRevert(bytes("APPROVER_ADDR is required")); |
| 269 | script.run(); |
| 270 | vm.setEnv("APPROVER_ADDR", vm.toString(approver)); |
| 271 | vm.setEnv("EMERGENCY_GUARDIAN_ADDR", ""); |
| 272 | vm.expectRevert(bytes("EMERGENCY_GUARDIAN_ADDR is required")); |
| 273 | script.run(); |
| 274 | // overlapping |
| 275 | vm.setEnv("EMERGENCY_GUARDIAN_ADDR", vm.toString(owner)); |
| 276 | vm.expectRevert(bytes("EMERGENCY_GUARDIAN_ADDR must differ from the owner (deployer)")); |
| 277 | script.run(); |
| 278 | vm.setEnv("EMERGENCY_GUARDIAN_ADDR", vm.toString(approver)); |
| 279 | vm.expectRevert(bytes("APPROVER_ADDR and EMERGENCY_GUARDIAN_ADDR must differ")); |
| 280 | script.run(); |
| 281 | vm.setEnv("EMERGENCY_GUARDIAN_ADDR", vm.toString(emergency)); |
| 282 | // attester missing or shared with another role |
| 283 | vm.setEnv("ATTESTER_ADDR", ""); |
| 284 | vm.expectRevert(bytes("ATTESTER_ADDR is required")); |
| 285 | script.run(); |
| 286 | vm.setEnv("ATTESTER_ADDR", vm.toString(approver)); |
| 287 | vm.expectRevert(bytes("ATTESTER_ADDR must differ from APPROVER_ADDR")); |
| 288 | script.run(); |
| 289 | vm.setEnv("ATTESTER_ADDR", vm.toString(attester.addr)); |
| 290 | // the mock forwarder without the explicit opt-in |
| 291 | string memory refused = "mock forwarder refused: set ALLOW_MOCK_FORWARDER=1, or WORKFLOW_ID and WORKFLOW_OWNER"; |
| 292 | vm.setEnv("ALLOW_MOCK_FORWARDER", ""); |
| 293 | vm.expectRevert(bytes(refused)); |
| 294 | script.run(); |
| 295 | vm.setEnv("ALLOW_MOCK_FORWARDER", "0"); |
| 296 | vm.expectRevert(bytes(refused)); |
| 297 | script.run(); |
| 298 | vm.setEnv("ALLOW_MOCK_FORWARDER", "1"); |
| 299 | |
| 300 | uint256 startBlock = vm.getBlockNumber(); |
| 301 | script.run(); |
| 302 | |
| 303 | string memory j = vm.readFile(out); |
| 304 | vm.removeFile(out); |
| 305 | string[] memory keys = vm.parseJsonKeys(j, "$"); |
| 306 | string[21] memory want = [ |
| 307 | "chainId", |
| 308 | "startBlock", |
| 309 | "owner", |
| 310 | "approver", |
| 311 | "emergencyGuardian", |
| 312 | "attester", |
| 313 | "forwarder", |
| 314 | "delayBlocks", |
| 315 | "holders", |
| 316 | "proxy", |
| 317 | "proxyAdmin", |
| 318 | "receiver", |
| 319 | "airlockAdmin", |
| 320 | "v1", |
| 321 | "v2", |
| 322 | "evilMint", |
| 323 | "evilOwner", |
| 324 | "evilFee", |
| 325 | "evilBaseFee", |
| 326 | "evilDrain", |
| 327 | "paused" |
| 328 | ]; |
| 329 | assertEq(keys.length, want.length, "JSON key count"); |
| 330 | for (uint256 i = 0; i < want.length; i++) { |
| 331 | assertTrue(vm.keyExistsJson(j, string.concat(".", want[i])), want[i]); |
| 332 | } |
| 333 | assertEq(vm.parseJsonUint(j, ".chainId"), block.chainid); |
| 334 | assertEq(vm.parseJsonUint(j, ".startBlock"), startBlock); |
| 335 | assertEq(vm.parseJsonUint(j, ".delayBlocks"), 25); |
| 336 | assertEq(vm.parseJsonAddress(j, ".owner"), owner); |
| 337 | assertEq(vm.parseJsonAddress(j, ".approver"), approver); |
| 338 | assertEq(vm.parseJsonAddress(j, ".emergencyGuardian"), emergency); |
| 339 | assertEq(vm.parseJsonAddress(j, ".attester"), attester.addr); |
| 340 | assertEq(vm.parseJsonAddress(j, ".forwarder"), MOCK_FORWARDER); |
| 341 | assertEq(vm.parseJsonAddressArray(j, ".holders").length, 3); |
| 342 | |
| 343 | AirlockAdmin a = AirlockAdmin(vm.parseJsonAddress(j, ".airlockAdmin")); |
| 344 | assertTrue(a.isWired()); |
| 345 | assertEq(a.clearanceOracle(), vm.parseJsonAddress(j, ".receiver")); |
| 346 | assertEq(a.proxy(), vm.parseJsonAddress(j, ".proxy")); |
| 347 | assertEq(a.pausedImpl(), vm.parseJsonAddress(j, ".paused")); |
| 348 | assertGt(vm.parseJsonAddress(j, ".evilBaseFee").code.length, 0); |
| 349 | assertGt(vm.parseJsonAddress(j, ".evilDrain").code.length, 0); |
| 350 | } |
| 351 | } |