Upgrade Airlock war room
The live view of Upgrade Airlock on Ethereum Sepolia. The browser reads the chain directly (read-only: eth_call,
eth_getLogs, receipts and block headers; it never signs or sends) and draws every proposal as an airlock. The one
exception is operator mode, a small backend that sends one of seven fixed
proposals for the person running a demo; the page still never holds a key.
- Outer door: the proposal enters the chamber.
- Chamber: the cooldown counts down in blocks and in minutes and seconds.
- Inner door: locked until a Chainlink CRE Clear lands on chain, then opens when the upgrade executes. A Veto ejects the proposal with its rule, the blocks and seconds it took, and the report transaction.
Beside it, the Chainlink CRE verdict card shows the steps the workflow ran for that proposal, the rule in plain
words and links to the proposal tx, the report tx and the evidence hash. An upgrade goes through the attack
simulation on 4 RPC providers and "all answers agree"; a change to the airlock itself (guardians, cooldown,
break-glass target) gets a config check from chain reads at the proposal block instead (R7), and an upgrade stopped by
a static rule (R5, R8) never reaches the simulation. In every case the workflow's consensus step (identical verdict
and rule), the signed report and the write on chain follow. The verdicts on Sepolia so far came from the official CRE
CLI running the workflow on a server, where the consensus step runs on one node; the same workflow and contract code
can be deployed to a DON, where every node runs these steps and the nodes must agree before a report is signed. The
strip at the bottom holds the recent proposals; click one to show it. Hashes, gas costs, the connection and the event
stream are in the Details drawer (D), hidden by default.
The cards keep the first veto of a proposal. Anyone can re-send a mined veto report until it expires (on Sepolia the
forwarder is Chainlink's permissionless test forwarder); AirlockAdmin then emits Vetoed again, but the proposal's
state does not change, so the re-sent copy shows up in the event stream only, never as the veto's block, transaction
or workflow id.
Attack picker (what a visitor sees)
"Pick an attack and watch Chainlink CRE stop it": seven cards, one per demo action of scripts/sepolia/propose.sh
(evil-mint R1, evil-owner R2, evil-fee R3, evil-basefee R9, evil-drain R3B, remove-receiver R7, and the honest v2).
A card replays, from the very beginning, the newest real Sepolia proposal of that attack: it reaches the outer
door, waits in the chamber while the verdict card walks through the workflow's steps, then the Veto ejects it (or the
Clear unlocks the inner door, the rest of the cooldown is fast-forwarded and v2 executes). Each replay says it is one:
"Replay · a real Sepolia proposal ·
A first visit (not with ?clean=1 or ?p=) shows a short intro while the chain data loads, then plays the
evil-drain replay on its own and invites the visitor to pick another card; it never shows a final verdict before the
replay that leads up to it. The page reads that proposal's logs first and starts its replay as soon as they are in,
while the other cards' history loads a few proposals at a time; if the public Sepolia RPCs do not answer for 12 s,
the intro says so and links the recorded run (?mode=offline). During a replay the chamber also shows the CRE
workflow step the verdict card is on (so a phone sees it without scrolling), notes that the clock is compressed, and
marks the fast-forwarded cooldown (×80). After a replay the proposal's real state stays on screen, tagged with its
date ("On-chain state · proposed ?clean=1 hides it (keys 1-7 still work).
Run
npm ci
npm run dev # http://127.0.0.1:5173/
npm run build:airlock # syncs ../deployments/sepolia.json, builds dist-airlock/
npx vite preview --outDir dist-airlock --port 4173
npm run typecheck && npm run selftest # 394 checks: 282 live view and attack picker, 33 offline replay, 79 operator API
394 includes 7 checks that run only when the contracts' forge build output (../out/, from
FOUNDRY_PROFILE=eth forge build at the repo root) and cast are there: they compare the app's ABIs with the forge
artifacts and its calldata with cast calldata. Without them those 7 are skipped and the run prints 387 ok lines
(277 + 33 + 77). The CI web job has neither, so CI runs 387.
The proposals' history (eth_getLogs from each proposal's block) is read from drpc first, then tenderly and thirdweb,
with publicnode last (it prunes logs older than about a day); a failed call moves on to the next endpoint.
URL parameters
| Parameter | Effect |
|---|---|
?mode=airlock |
Default. Live view of Ethereum Sepolia |
?mode=offline |
Replays the recorded Sepolia run (src/recorded-run.json) with no network connection at all |
?lang=en, ?lang=zh-CN, ?lang=zh-TW |
Interface language (default zh-CN) |
?p=<id> |
Opens on that proposal, also one older than the recent ones (it is read too); an id the chain does not have yet says "No proposal #id". Offline, the replay loop starts with it |
?clean=1 |
Recording mode: no language switch, Details, ? or key hints (the Chainlink CRE mark stays). Use it for the demo video |
?rpc=http://127.0.0.1:<port> |
Reads a local Sepolia fork instead (rehearsals), labelled "local fork". Loopback addresses only (127.x, localhost, [::1]); any other host is ignored |
?op=1 |
Operator mode: asks for the operator token (recommended on a stage machine: the token never sits in an address) |
#op=<token> / ?op=<token> |
Operator mode: stores the operator token in this browser and removes it from the address bar (a # fragment never reaches the server) |
Presenter controls
| Key | Action |
|---|---|
← / → (also Page Up / Page Down, for a clicker) |
Previous (older) / next (newer) proposal |
N (also Home / End) |
Jump to the newest proposal |
Space (also Enter) |
Replay the selected proposal's verdict moment: arrival, cooldown fast-forwarded, verdict, inner door (pressed while its history still loads, it plays as soon as it is in) |
F |
Auto-follow on / off (also the toggle above the strip). On (the default), each new live proposal takes over the screen and plays its arrival and verdict as they land. Offline, F toggles the replay loop |
P |
Play / pause the replay loop (offline it runs from page load; live it plays the recent proposals) |
D |
Details drawer |
L |
Switch language |
? (also H) |
On-screen help with these keys |
1 - 7 |
Replay attack card 1-7 of the picker (left to right), also with ?clean=1 |
O |
Operator panel (only where operator mode is on for this browser) |
Esc |
Close the drawer, the help and the operator panel |
A live event always wins: when a proposal or verdict arrives, any replay stops and the real state is shown.
On a stage screen with a mouse, the presenter controls (language switch, Details, ?, the key hints) and the cursor
fade out after 3 s without mouse movement and come back when the mouse moves; keys never bring them back, so a
clicker keeps the screen clean. Touch screens never show key hints. ?clean=1 never draws them at all.
Layout
A landscape screen at least 960 px wide and 5:4 or wider gets a fixed 1920×1080 stage scaled to the window and
letterboxed, so a 1920×1080 or 1280×720 projector, a 4:3 or 5:4 projector and the recorded video all show the same
composition. Phones, portrait screens and narrow windows get one scrolling column (the doors become rows, the stamps
size themselves to the chamber). Fonts are system fonts only (no web fonts or CDNs), and prefers-reduced-motion
turns every animation off (the final state of each change is shown at once).
The moments a change of state plays: the outer door closes behind an arriving proposal; a veto shakes the capsule, drops it out and stamps EJECTED with the rule; a clearance sweeps the chamber, stamps CLEARED for about 1.5 s and unlocks the inner door; an execution opens the inner door and the proxy lights up. Green means a clearance only, red a veto only; "live", the presenter toggles and every other accent are Chainlink blue.
Files
| File | What it does |
|---|---|
src/ui.ts |
Every region of the screen, the state "moments" (arrive, veto, clear, execute) |
src/style.css |
Stage and stack layouts, the airlock picture, the animations |
src/controls.ts |
Key map, help rows, stepping through proposals |
src/i18n.ts |
All words in zh-CN, zh-TW and en |
src/airlock.ts |
The airlock state of one proposal (pure) |
src/lab.ts, src/lab-ui.ts |
Attack picker: card -> proposal, first-visit intro and autoplay, the picker and the operator panel drawn |
src/operator-client.ts |
Operator mode in the page: GET /api/operator, the token (?op=1, #op=), launches and their status |
src/view.ts |
Small shared drawing helpers (links, hashes, icons) |
src/replay.ts, src/playback.ts |
Replay engine on a virtual block clock, bound to the page |
src/chainwatch.ts, src/chain.ts, src/events.ts |
Live reads of Sepolia (WebSocket with HTTP fallback, eth_getLogs cursor) |
src/offline.ts, src/recorded-run.json |
Offline replay of the recorded run (npm run record:run writes it) |
operator/ |
Operator backend shared by the dev server and the Pages Functions: the seven cards (cards.ts), checks, signing and sending (operator.ts), Request/Response handlers (cloud.ts), the deployment data (deployment.generated.ts, written by npm run sync:sepolia) |
functions/api/operator/ |
Cloudflare Pages Functions: GET /api/operator, POST /api/operator/propose |
test/ |
Self-tests (npm run selftest, 394 checks; 387 without the forge build output in ../out/ and cast, as in CI): live view and attack picker against a fake Sepolia (282), offline replay with every network call trapped (33), the operator API against a fake Sepolia (79) |
Operator mode (real attacks on Sepolia)
For the person running a demo only. The page gets an operator panel that launches one of the seven cards as a real
proposal on Ethereum Sepolia; the live war room then picks up the proposal and its Chainlink CRE verdict like any
other. Visitors never see it: the page only asks GET /api/operator on a loopback host (local dev), when this browser
already holds the operator token, or with ?op=1, so a public visitor's page never calls the API at all. The panel
appears only when the API says enabled and either it needs no token (the local dev server) or this browser holds the
token. A static deploy has no API at all (vite preview answers enabled: false). The API itself is public, though:
GET /api/operator tells anyone who asks whether operator mode is on, its auth kind, the card ids and the cooldown
(nothing else); only POST /api/operator/propose needs the token.
Local
AIRLOCK_OPERATOR=1 AIRLOCK_KEYS_DIR=<folder with owner.key> npm run dev # http://127.0.0.1:5173/
- The owner key is read from
AIRLOCK_OWNER_KEY_FILE(default:owner.keyinAIRLOCK_KEYS_DIR) on every request; it is never cached, printed or logged. The startup line says whether the file is readable. - No token (auth
none), loopback only: the connecting peer address must itself be loopback (127/8,::1), so another device on the same Wi-Fi gets 403 even if it sendsHost: 127.0.0.1. The request must also carry Host127.0.0.1:<port>orlocalhost:<port>and, when the browser sends one, the same Origin, so other sites and DNS-rebound names get 403. With a non-loopbackserver.host(vite --host,0.0.0.0, a LAN address) the operator stays off and the startup line says why. AIRLOCK_OPERATOR_RPC=<url>[,<url>]replaces the Sepolia RPCs (a local fork). WithAIRLOCK_DEPLOYMENTit is required, so fork addresses are never sent to Sepolia.- Without
AIRLOCK_OPERATOR=1both routes sayenabled: false.vite previewand the static build have no API.
Cloud (Cloudflare Pages Functions)
functions/api/operator/ serves the same API from the Pages project, with token auth. It reads two secrets and is
off unless both exist: OWNER_KEY (the owner's key, hex) and OPERATOR_TOKEN (32 characters or more; use 64 hex).
Give them to wrangler on stdin, never on the command line:
tr -d ' \n' < "$AIRLOCK_KEYS_DIR/owner.key" | npx wrangler@3 pages secret put OWNER_KEY --project-name <project>
(umask 077; openssl rand -hex 32 > "$AIRLOCK_KEYS_DIR/operator.token")
tr -d '\n' < "$AIRLOCK_KEYS_DIR/operator.token" | npx wrangler@3 pages secret put OPERATOR_TOKEN --project-name <project>
Secrets apply from the next deployment. Deploy with wrangler pages deploy <static dir> run from a folder that holds
functions/, operator/ and a node_modules with viem, so wrangler compiles the Functions into the Worker; check
that GET /api/operator answers JSON. Then open the page once with ?op=1 and paste the token (safest on a
projector: no address-bar suggestion can show it), or with #op=<token>, which never reaches the server's request log
(?op=<token> works too, but does).
Deleting either secret and deploying turns the operator off on the production URL only. Cloudflare keeps every
deployment with the secrets it was deployed with, so each older deployment's own https://<hash>.<project>.pages.dev
URL keeps a working operator API with the old key and token (and a rollback to one turns it back on) until that
deployment is deleted; a new token is no different. After the event: delete both secrets, deploy, delete every
deployment made while the secrets were set (Cloudflare dashboard, or wrangler pages deployment delete in wrangler 4),
then check that GET https://<hash>.<project>.pages.dev/api/operator answers 404 or enabled: false for each. The
owner key itself cannot be rotated on chain (AirlockAdmin.owner is immutable), which is why the old deployments must
go.
API
| Request | Answers |
|---|---|
GET /api/operator |
200 {enabled, auth: "none" | "token", cards, cooldownBlocks}; off: {enabled: false, auth: "token", cards: [], cooldownBlocks: 0} |
POST /api/operator/propose with {"card": "<id>"} and, for auth token, Authorization: Bearer <token> |
200 {ok: true, card, txHash} · 400 unknown card · 401 unauthorized · 409 cooldown with retryAfterBlocks · 502 rpc |
Cards: evil-mint, evil-owner, evil-fee, evil-basefee, evil-drain, remove-receiver, v2. Three more answers
only a setup problem or the chain itself causes: 403 disabled (operator off), 422 reverted (the dry run reverted,
nothing was sent), 500 misconfigured (the key is not the owner's). Errors are {ok: false, error} with a fixed word.
200 means sent, not mined: the page shows the transaction as pending until the war room sees the proposal.
The page gives every answer its own sentence (a refused origin 403 forbidden, a switched-off operator 403 disabled,
422, 500 misconfigured and so on) and never shows the server's own words. A 401 forgets the stored token and
hides the operator UI; 403 disabled hides it too.
In the page, the bolt button in the top bar (or O) opens the launch panel; each card also gets a "Launch" button.
Every launch has a confirm step. After the server answers with a transaction hash, auto-follow is turned on, the
airlock shows the launch as pending (with its Etherscan link) and the live war room takes over as soon as the proposal
is on chain, then shows its Chainlink CRE verdict. With ?clean=1 only O opens the panel.
Safety limits
- Seven fixed actions only, the same as
scripts/sepolia/propose.sh:proposeUpgrade(<impl>, 0x)for the six upgrade cards andproposeSetGuardian(<receiver>, false)forremove-receiver. The client sends a card id, never an address or calldata; the addresses are bundled at build time (operator/deployment.generated.ts). - Before anything is sent: the key must belong to the AirlockAdmin owner (checked before any network call); the RPC must be Sepolia; the newest proposal must be at least 3 blocks old and the owner must have nothing pending (409); a dry run as the owner must not revert (422).
- One transaction per request: EIP-1559, gas limit 1,000,000 (
proposeUpgradeused 594,008 on Sepolia), nonce and fees from the node (max fee 2 x base fee + tip, tip at least 1.5 gwei, never above 500 gwei). If publicnode fails, drpc gets the same signed bytes, so a retry cannot send twice. - No response or log line carries the key, the token or a node's error text. The token check is constant-time (both
sides are hashed first). Responses are JSON with
Cache-Control: no-store.
Source
| 1 | # Upgrade Airlock war room |
| 2 | |
| 3 | The live view of Upgrade Airlock on Ethereum Sepolia. The browser reads the chain directly (read-only: `eth_call`, |
| 4 | `eth_getLogs`, receipts and block headers; it never signs or sends) and draws every proposal as an airlock. The one |
| 5 | exception is [operator mode](#operator-mode-real-attacks-on-sepolia), a small backend that sends one of seven fixed |
| 6 | proposals for the person running a demo; the page still never holds a key. |
| 7 | |
| 8 | - **Outer door**: the proposal enters the chamber. |
| 9 | - **Chamber**: the cooldown counts down in blocks and in minutes and seconds. |
| 10 | - **Inner door**: locked until a Chainlink CRE **Clear** lands on chain, then opens when the upgrade executes. |
| 11 | A **Veto** ejects the proposal with its rule, the blocks and seconds it took, and the report transaction. |
| 12 | |
| 13 | Beside it, the **Chainlink CRE verdict** card shows the steps the workflow ran for that proposal, the rule in plain |
| 14 | words and links to the proposal tx, the report tx and the evidence hash. An upgrade goes through the attack |
| 15 | simulation on 4 RPC providers and "all answers agree"; a change to the airlock itself (guardians, cooldown, |
| 16 | break-glass target) gets a config check from chain reads at the proposal block instead (R7), and an upgrade stopped by |
| 17 | a static rule (R5, R8) never reaches the simulation. In every case the workflow's consensus step (identical verdict |
| 18 | and rule), the signed report and the write on chain follow. The verdicts on Sepolia so far came from the official CRE |
| 19 | CLI running the workflow on a server, where the consensus step runs on one node; the same workflow and contract code |
| 20 | can be deployed to a DON, where every node runs these steps and the nodes must agree before a report is signed. The |
| 21 | strip at the bottom holds the recent proposals; click one to show it. Hashes, gas costs, the connection and the event |
| 22 | stream are in the **Details** drawer (`D`), hidden by default. |
| 23 | |
| 24 | The cards keep the first veto of a proposal. Anyone can re-send a mined veto report until it expires (on Sepolia the |
| 25 | forwarder is Chainlink's permissionless test forwarder); AirlockAdmin then emits `Vetoed` again, but the proposal's |
| 26 | state does not change, so the re-sent copy shows up in the event stream only, never as the veto's block, transaction |
| 27 | or workflow id. |
| 28 | |
| 29 | ## Attack picker (what a visitor sees) |
| 30 | |
| 31 | "Pick an attack and watch Chainlink CRE stop it": seven cards, one per demo action of `scripts/sepolia/propose.sh` |
| 32 | (evil-mint R1, evil-owner R2, evil-fee R3, evil-basefee R9, evil-drain R3B, remove-receiver R7, and the honest v2). |
| 33 | A card replays, from the very beginning, the newest **real** Sepolia proposal of that attack: it reaches the outer |
| 34 | door, waits in the chamber while the verdict card walks through the workflow's steps, then the Veto ejects it (or the |
| 35 | Clear unlocks the inner door, the rest of the cooldown is fast-forwarded and v2 executes). Each replay says it is one: |
| 36 | "Replay · a real Sepolia proposal · <date>", the proposal number and its transaction links; the real distance from |
| 37 | proposal to verdict stays on screen ("1 block · 12 s after the proposal"). A card whose attack is in its cooldown on |
| 38 | chain right now says "Live" and shows that proposal instead. |
| 39 | |
| 40 | A first visit (not with `?clean=1` or `?p=`) shows a short intro while the chain data loads, then plays the |
| 41 | evil-drain replay on its own and invites the visitor to pick another card; it never shows a final verdict before the |
| 42 | replay that leads up to it. The page reads that proposal's logs first and starts its replay as soon as they are in, |
| 43 | while the other cards' history loads a few proposals at a time; if the public Sepolia RPCs do not answer for 12 s, |
| 44 | the intro says so and links the recorded run (`?mode=offline`). During a replay the chamber also shows the CRE |
| 45 | workflow step the verdict card is on (so a phone sees it without scrolling), notes that the clock is compressed, and |
| 46 | marks the fast-forwarded cooldown (×80). After a replay the proposal's real state stays on screen, tagged with its |
| 47 | date ("On-chain state · proposed <date>"), never as live. A live proposal always wins: when one arrives, any replay |
| 48 | stops, auto-follow shows it and it is labelled Live (the "That was a real replay…" invite above the cards goes at the |
| 49 | same moment); a live verdict on the newest proposal also takes the screen back from a picker replay. On a stage |
| 50 | screen the picker shares the bottom row with the strip of recent proposals (two tabs); on a phone it is a row of cards |
| 51 | to swipe, right under the top bar. `?clean=1` hides it (keys `1`-`7` still work). |
| 52 | |
| 53 | ## Run |
| 54 | |
| 55 | ```bash |
| 56 | npm ci |
| 57 | npm run dev # http://127.0.0.1:5173/ |
| 58 | npm run build:airlock # syncs ../deployments/sepolia.json, builds dist-airlock/ |
| 59 | npx vite preview --outDir dist-airlock --port 4173 |
| 60 | npm run typecheck && npm run selftest # 394 checks: 282 live view and attack picker, 33 offline replay, 79 operator API |
| 61 | ``` |
| 62 | |
| 63 | 394 includes 7 checks that run only when the contracts' forge build output (`../out/`, from |
| 64 | `FOUNDRY_PROFILE=eth forge build` at the repo root) and `cast` are there: they compare the app's ABIs with the forge |
| 65 | artifacts and its calldata with `cast calldata`. Without them those 7 are skipped and the run prints 387 `ok` lines |
| 66 | (277 + 33 + 77). The CI web job has neither, so CI runs 387. |
| 67 | |
| 68 | The proposals' history (`eth_getLogs` from each proposal's block) is read from drpc first, then tenderly and thirdweb, |
| 69 | with publicnode last (it prunes logs older than about a day); a failed call moves on to the next endpoint. |
| 70 | |
| 71 | ## URL parameters |
| 72 | |
| 73 | | Parameter | Effect | |
| 74 | |---|---| |
| 75 | | `?mode=airlock` | Default. Live view of Ethereum Sepolia | |
| 76 | | `?mode=offline` | Replays the recorded Sepolia run (`src/recorded-run.json`) with no network connection at all | |
| 77 | | `?lang=en`, `?lang=zh-CN`, `?lang=zh-TW` | Interface language (default `zh-CN`) | |
| 78 | | `?p=<id>` | Opens on that proposal, also one older than the recent ones (it is read too); an id the chain does not have yet says "No proposal #id". Offline, the replay loop starts with it | |
| 79 | | `?clean=1` | Recording mode: no language switch, Details, `?` or key hints (the Chainlink CRE mark stays). Use it for the demo video | |
| 80 | | `?rpc=http://127.0.0.1:<port>` | Reads a local Sepolia fork instead (rehearsals), labelled "local fork". Loopback addresses only (`127.x`, `localhost`, `[::1]`); any other host is ignored | |
| 81 | | `?op=1` | Operator mode: asks for the operator token (recommended on a stage machine: the token never sits in an address) | |
| 82 | | `#op=<token>` / `?op=<token>` | Operator mode: stores the operator token in this browser and removes it from the address bar (a `#` fragment never reaches the server) | |
| 83 | |
| 84 | ## Presenter controls |
| 85 | |
| 86 | | Key | Action | |
| 87 | |---|---| |
| 88 | | `←` / `→` (also Page Up / Page Down, for a clicker) | Previous (older) / next (newer) proposal | |
| 89 | | `N` (also Home / End) | Jump to the newest proposal | |
| 90 | | `Space` (also Enter) | Replay the selected proposal's verdict moment: arrival, cooldown fast-forwarded, verdict, inner door (pressed while its history still loads, it plays as soon as it is in) | |
| 91 | | `F` | Auto-follow on / off (also the toggle above the strip). On (the default), each new live proposal takes over the screen and plays its arrival and verdict as they land. Offline, `F` toggles the replay loop | |
| 92 | | `P` | Play / pause the replay loop (offline it runs from page load; live it plays the recent proposals) | |
| 93 | | `D` | Details drawer | |
| 94 | | `L` | Switch language | |
| 95 | | `?` (also `H`) | On-screen help with these keys | |
| 96 | | `1` - `7` | Replay attack card 1-7 of the picker (left to right), also with `?clean=1` | |
| 97 | | `O` | Operator panel (only where [operator mode](#operator-mode-real-attacks-on-sepolia) is on for this browser) | |
| 98 | | `Esc` | Close the drawer, the help and the operator panel | |
| 99 | |
| 100 | A live event always wins: when a proposal or verdict arrives, any replay stops and the real state is shown. |
| 101 | |
| 102 | On a stage screen with a mouse, the presenter controls (language switch, Details, `?`, the key hints) and the cursor |
| 103 | fade out after 3 s without mouse movement and come back when the mouse moves; keys never bring them back, so a |
| 104 | clicker keeps the screen clean. Touch screens never show key hints. `?clean=1` never draws them at all. |
| 105 | |
| 106 | ## Layout |
| 107 | |
| 108 | A landscape screen at least 960 px wide and 5:4 or wider gets a fixed 1920×1080 stage scaled to the window and |
| 109 | letterboxed, so a 1920×1080 or 1280×720 projector, a 4:3 or 5:4 projector and the recorded video all show the same |
| 110 | composition. Phones, portrait screens and narrow windows get one scrolling column (the doors become rows, the stamps |
| 111 | size themselves to the chamber). Fonts are system fonts only (no web fonts or CDNs), and `prefers-reduced-motion` |
| 112 | turns every animation off (the final state of each change is shown at once). |
| 113 | |
| 114 | The moments a change of state plays: the outer door closes behind an arriving proposal; a veto shakes the capsule, |
| 115 | drops it out and stamps EJECTED with the rule; a clearance sweeps the chamber, stamps CLEARED for about 1.5 s and |
| 116 | unlocks the inner door; an execution opens the inner door and the proxy lights up. Green means a clearance only, |
| 117 | red a veto only; "live", the presenter toggles and every other accent are Chainlink blue. |
| 118 | |
| 119 | ## Files |
| 120 | |
| 121 | | File | What it does | |
| 122 | |---|---| |
| 123 | | `src/ui.ts` | Every region of the screen, the state "moments" (arrive, veto, clear, execute) | |
| 124 | | `src/style.css` | Stage and stack layouts, the airlock picture, the animations | |
| 125 | | `src/controls.ts` | Key map, help rows, stepping through proposals | |
| 126 | | `src/i18n.ts` | All words in zh-CN, zh-TW and en | |
| 127 | | `src/airlock.ts` | The airlock state of one proposal (pure) | |
| 128 | | `src/lab.ts`, `src/lab-ui.ts` | Attack picker: card -> proposal, first-visit intro and autoplay, the picker and the operator panel drawn | |
| 129 | | `src/operator-client.ts` | Operator mode in the page: `GET /api/operator`, the token (`?op=1`, `#op=`), launches and their status | |
| 130 | | `src/view.ts` | Small shared drawing helpers (links, hashes, icons) | |
| 131 | | `src/replay.ts`, `src/playback.ts` | Replay engine on a virtual block clock, bound to the page | |
| 132 | | `src/chainwatch.ts`, `src/chain.ts`, `src/events.ts` | Live reads of Sepolia (WebSocket with HTTP fallback, `eth_getLogs` cursor) | |
| 133 | | `src/offline.ts`, `src/recorded-run.json` | Offline replay of the recorded run (`npm run record:run` writes it) | |
| 134 | | `operator/` | Operator backend shared by the dev server and the Pages Functions: the seven cards (`cards.ts`), checks, signing and sending (`operator.ts`), Request/Response handlers (`cloud.ts`), the deployment data (`deployment.generated.ts`, written by `npm run sync:sepolia`) | |
| 135 | | `functions/api/operator/` | Cloudflare Pages Functions: `GET /api/operator`, `POST /api/operator/propose` | |
| 136 | | `test/` | Self-tests (`npm run selftest`, 394 checks; 387 without the forge build output in `../out/` and `cast`, as in CI): live view and attack picker against a fake Sepolia (282), offline replay with every network call trapped (33), the operator API against a fake Sepolia (79) | |
| 137 | |
| 138 | ## Operator mode (real attacks on Sepolia) |
| 139 | |
| 140 | For the person running a demo only. The page gets an operator panel that launches one of the seven cards as a real |
| 141 | proposal on Ethereum Sepolia; the live war room then picks up the proposal and its Chainlink CRE verdict like any |
| 142 | other. Visitors never see it: the page only asks `GET /api/operator` on a loopback host (local dev), when this browser |
| 143 | already holds the operator token, or with `?op=1`, so a public visitor's page never calls the API at all. The panel |
| 144 | appears only when the API says `enabled` and either it needs no token (the local dev server) or this browser holds the |
| 145 | token. A static deploy has no API at all (`vite preview` answers `enabled: false`). The API itself is public, though: |
| 146 | `GET /api/operator` tells anyone who asks whether operator mode is on, its auth kind, the card ids and the cooldown |
| 147 | (nothing else); only `POST /api/operator/propose` needs the token. |
| 148 | |
| 149 | ### Local |
| 150 | |
| 151 | ```bash |
| 152 | AIRLOCK_OPERATOR=1 AIRLOCK_KEYS_DIR=<folder with owner.key> npm run dev # http://127.0.0.1:5173/ |
| 153 | ``` |
| 154 | |
| 155 | - The owner key is read from `AIRLOCK_OWNER_KEY_FILE` (default: `owner.key` in `AIRLOCK_KEYS_DIR`) on every request; |
| 156 | it is never cached, printed or logged. The startup line says whether the file is readable. |
| 157 | - No token (auth `none`), loopback only: the connecting peer address must itself be loopback (127/8, `::1`), so |
| 158 | another device on the same Wi-Fi gets 403 even if it sends `Host: 127.0.0.1`. The request must also carry Host |
| 159 | `127.0.0.1:<port>` or `localhost:<port>` and, when the browser sends one, the same Origin, so other sites and |
| 160 | DNS-rebound names get 403. With a non-loopback `server.host` (`vite --host`, `0.0.0.0`, a LAN address) the operator |
| 161 | stays off and the startup line says why. |
| 162 | - `AIRLOCK_OPERATOR_RPC=<url>[,<url>]` replaces the Sepolia RPCs (a local fork). With `AIRLOCK_DEPLOYMENT` it is |
| 163 | required, so fork addresses are never sent to Sepolia. |
| 164 | - Without `AIRLOCK_OPERATOR=1` both routes say `enabled: false`. `vite preview` and the static build have no API. |
| 165 | |
| 166 | ### Cloud (Cloudflare Pages Functions) |
| 167 | |
| 168 | `functions/api/operator/` serves the same API from the Pages project, with token auth. It reads two secrets and is |
| 169 | off unless both exist: `OWNER_KEY` (the owner's key, hex) and `OPERATOR_TOKEN` (32 characters or more; use 64 hex). |
| 170 | Give them to wrangler on stdin, never on the command line: |
| 171 | |
| 172 | ```bash |
| 173 | tr -d ' \n' < "$AIRLOCK_KEYS_DIR/owner.key" | npx wrangler@3 pages secret put OWNER_KEY --project-name <project> |
| 174 | (umask 077; openssl rand -hex 32 > "$AIRLOCK_KEYS_DIR/operator.token") |
| 175 | tr -d '\n' < "$AIRLOCK_KEYS_DIR/operator.token" | npx wrangler@3 pages secret put OPERATOR_TOKEN --project-name <project> |
| 176 | ``` |
| 177 | |
| 178 | Secrets apply from the next deployment. Deploy with `wrangler pages deploy <static dir>` run from a folder that holds |
| 179 | `functions/`, `operator/` and a `node_modules` with viem, so wrangler compiles the Functions into the Worker; check |
| 180 | that `GET /api/operator` answers JSON. Then open the page once with `?op=1` and paste the token (safest on a |
| 181 | projector: no address-bar suggestion can show it), or with `#op=<token>`, which never reaches the server's request log |
| 182 | (`?op=<token>` works too, but does). |
| 183 | |
| 184 | Deleting either secret and deploying turns the operator off on the production URL only. Cloudflare keeps every |
| 185 | deployment with the secrets it was deployed with, so each older deployment's own `https://<hash>.<project>.pages.dev` |
| 186 | URL keeps a working operator API with the old key and token (and a rollback to one turns it back on) until that |
| 187 | deployment is deleted; a new token is no different. After the event: delete both secrets, deploy, delete every |
| 188 | deployment made while the secrets were set (Cloudflare dashboard, or `wrangler pages deployment delete` in wrangler 4), |
| 189 | then check that `GET https://<hash>.<project>.pages.dev/api/operator` answers 404 or `enabled: false` for each. The |
| 190 | owner key itself cannot be rotated on chain (`AirlockAdmin.owner` is immutable), which is why the old deployments must |
| 191 | go. |
| 192 | |
| 193 | ### API |
| 194 | |
| 195 | | Request | Answers | |
| 196 | |---|---| |
| 197 | | `GET /api/operator` | `200 {enabled, auth: "none" \| "token", cards, cooldownBlocks}`; off: `{enabled: false, auth: "token", cards: [], cooldownBlocks: 0}` | |
| 198 | | `POST /api/operator/propose` with `{"card": "<id>"}` and, for auth `token`, `Authorization: Bearer <token>` | `200 {ok: true, card, txHash}` · `400 unknown card` · `401 unauthorized` · `409 cooldown` with `retryAfterBlocks` · `502 rpc` | |
| 199 | |
| 200 | Cards: `evil-mint`, `evil-owner`, `evil-fee`, `evil-basefee`, `evil-drain`, `remove-receiver`, `v2`. Three more answers |
| 201 | only a setup problem or the chain itself causes: `403 disabled` (operator off), `422 reverted` (the dry run reverted, |
| 202 | nothing was sent), `500 misconfigured` (the key is not the owner's). Errors are `{ok: false, error}` with a fixed word. |
| 203 | `200` means sent, not mined: the page shows the transaction as pending until the war room sees the proposal. |
| 204 | The page gives every answer its own sentence (a refused origin `403 forbidden`, a switched-off operator `403 disabled`, |
| 205 | `422`, `500 misconfigured` and so on) and never shows the server's own words. A `401` forgets the stored token and |
| 206 | hides the operator UI; `403 disabled` hides it too. |
| 207 | |
| 208 | In the page, the bolt button in the top bar (or `O`) opens the launch panel; each card also gets a "Launch" button. |
| 209 | Every launch has a confirm step. After the server answers with a transaction hash, auto-follow is turned on, the |
| 210 | airlock shows the launch as pending (with its Etherscan link) and the live war room takes over as soon as the proposal |
| 211 | is on chain, then shows its Chainlink CRE verdict. With `?clean=1` only `O` opens the panel. |
| 212 | |
| 213 | ### Safety limits |
| 214 | |
| 215 | - Seven fixed actions only, the same as `scripts/sepolia/propose.sh`: `proposeUpgrade(<impl>, 0x)` for the six upgrade |
| 216 | cards and `proposeSetGuardian(<receiver>, false)` for `remove-receiver`. The client sends a card id, never an address |
| 217 | or calldata; the addresses are bundled at build time (`operator/deployment.generated.ts`). |
| 218 | - Before anything is sent: the key must belong to the AirlockAdmin owner (checked before any network call); the RPC |
| 219 | must be Sepolia; the newest proposal must be at least 3 blocks old and the owner must have nothing pending (409); a |
| 220 | dry run as the owner must not revert (422). |
| 221 | - One transaction per request: EIP-1559, gas limit 1,000,000 (`proposeUpgrade` used 594,008 on Sepolia), nonce and |
| 222 | fees from the node (max fee 2 x base fee + tip, tip at least 1.5 gwei, never above 500 gwei). If publicnode fails, |
| 223 | drpc gets the same signed bytes, so a retry cannot send twice. |
| 224 | - No response or log line carries the key, the token or a node's error text. The token check is constant-time (both |
| 225 | sides are hashed first). Responses are JSON with `Cache-Control: no-store`. |