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 / DeployEth.t.sol
test/DeployEth.t.sol351 lines · 15579 bytes
1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.37;
3
4import {Test, Vm} from "forge-std/Test.sol";
5import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
6import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
7
8import {DeployEth} from "../script/DeployEth.s.sol";
9import {AirlockAdmin} from "../src/AirlockAdmin.sol";
10import {DemoBTC} from "../src/DemoBTC.sol";
11import {DemoBTCV2} from "../src/DemoBTCV2.sol";
12import {EvilDrainImpl} from "../src/attack/EvilDrainImpl.sol";
13import {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.
18contract 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}