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 / web / src / lab.ts
web/src/lab.ts325 lines · 15844 bytes
1// The attack picker: "pick an attack and watch Chainlink CRE stop it". Seven cards, one per demo action of
2// scripts/sepolia/propose.sh. A card plays, from its arrival at the outer door, the replay of the newest REAL proposal
3// of that kind already judged on Ethereum Sepolia (the v2 card prefers one that was executed). A proposal of that kind
4// still in its cooldown is live: the card says so and shows it instead (live always wins over a replay).
5//
6// First visit: once the proposals are in, the page plays one replay on its own (evil-drain), then invites the viewer
7// to pick another card. Never with ?clean=1 (recording), never with ?p=<id>, never offline (the replay loop already
8// runs there), never after the viewer picked anything, never while a live proposal is in its cooldown. Until that
9// replay starts, the airlock shows a neutral intro (labIntro), so a first visitor never sees the final verdict before
10// the replay that leads up to it.
11//
12// The pure helpers (labIdOf, pickFor, autoplayWanted, stageOf) are what the self-test checks; the rest binds them to
13// the store and the page's replay engine.
14import { airlockView, type AirlockPhase } from './airlock'
15import { blocks } from './chain'
16import { DATA } from './chaindata'
17import { setManualPick } from './events'
18import { historyIn, liveBusy, playbackGate, replay, replayFrame } from './playback'
19import { buildTimeline, replayType, type PlayOpts, type ReplayType, type Timeline } from './replay'
20import { CLEAN, OFFLINE, PICK } from './runtime'
21import { store, type Proposal } from './store'
22import type { CardId, ImplName, RuleId } from './types'
23
24export type LabId = 'evil-mint' | 'evil-owner' | 'evil-fee' | 'evil-basefee' | 'evil-drain' | 'remove-receiver' | 'v2'
25
26export interface LabCard {
27 id: LabId
28 /** the demo implementation an upgrade card proposes (null: the guardian change) */
29 impl: CardId | null
30 /** the rule the CRE workflow is expected to veto it with (null: the honest upgrade, expected to clear) */
31 rule: RuleId | null
32 /** the AirlockAdmin call scripts/sepolia/propose.sh makes for it */
33 fn: 'proposeUpgrade' | 'proposeSetGuardian'
34}
35
36/** Picker order, left to right (keys 1-7). The ids are the operator API's card ids. */
37export const LAB_CARDS: readonly LabCard[] = [
38 { id: 'evil-mint', impl: 'evilMint', rule: 'R1_TOTAL_SUPPLY', fn: 'proposeUpgrade' },
39 { id: 'evil-owner', impl: 'evilOwner', rule: 'R2_OWNER', fn: 'proposeUpgrade' },
40 { id: 'evil-fee', impl: 'evilFee', rule: 'R3_TRANSFER_CONSERVATION', fn: 'proposeUpgrade' },
41 { id: 'evil-basefee', impl: 'evilBaseFee', rule: 'R9_NEW_OPCODES', fn: 'proposeUpgrade' },
42 { id: 'evil-drain', impl: 'evilDrain', rule: 'R3B_THIRD_PARTY_FUNDS', fn: 'proposeUpgrade' },
43 { id: 'remove-receiver', impl: null, rule: 'R7_CONFIG_GUARD', fn: 'proposeSetGuardian' },
44 { id: 'v2', impl: 'v2', rule: null, fn: 'proposeUpgrade' },
45]
46export const LAB_IDS: readonly LabId[] = LAB_CARDS.map((c) => c.id)
47export const isLabId = (x: unknown): x is LabId => typeof x === 'string' && (LAB_IDS as readonly string[]).includes(x)
48export const labCard = (id: LabId): LabCard => LAB_CARDS.find((c) => c.id === id)!
49
50/** The card the first visit plays on its own */
51export const AUTOPLAY_CARD: LabId = 'evil-drain'
52/** Wait this long after the proposals are in before the first-visit replay starts (the intro stays up meanwhile) */
53export const AUTOPLAY_DELAY_MS = 300
54/** Give up on the first-visit replay if it could not start within this long (a live proposal kept the screen) */
55export const AUTOPLAY_GIVE_UP_MS = 60_000
56/** A proposal that arrived live during this visit is labelled live this long after its arrival */
57export const LIVE_LABEL_MS = 15 * 60_000
58
59/** Timing of a picker replay: a readable arrival, a visible "judging" stretch before the verdict (the verdict card
60 * walks through the workflow's steps meanwhile), long holds on the verdict and the inner door, and the rest of the
61 * cooldown fast-forwarded after the verdict. The real verdict came 1-3 blocks (12-40 s) after the proposal; the
62 * replay compresses that, and its real block distance stays on screen ("1 block · 12 s after the proposal"). */
63export const LAB_PLAY: PlayOpts = {
64 msPerBlock: 700,
65 fastMsPerBlock: 150,
66 // lead: about 2.5 s from the start to the outer door, so a first-time viewer sees the capsule go in
67 dwellMs: { lead: 1100, proposed: 1800, judging: 4400, verdict: 4200, executed: 3600 },
68 gapMs: 2500,
69}
70
71// ---------- pure helpers ----------
72/** Which card a proposal is: an upgrade to one of the demo implementations, or the guardian change that removes the
73 * CRE receiver (kind 1, target = AirlockReceiver, value 0). Anything else is not a picker card. */
74export function labIdOf(p: Proposal, implNames: ReadonlyMap<string, ImplName>, receiver: string = DATA.receiver): LabId | null {
75 const kind = Number(p.kind)
76 if (kind === 1) return !!p.target && p.target.toLowerCase() === receiver.toLowerCase() && p.value === '0' ? 'remove-receiver' : null
77 if (kind !== 0 || !p.target) return null
78 const name = p.card ?? implNames.get(p.target.toLowerCase())
79 return LAB_CARDS.find((c) => c.impl !== null && c.impl === name)?.id ?? null
80}
81
82const executed = (p: Proposal) => !!p.execute || p.chain?.status === 3
83
84export interface LabPick {
85 /** the proposal the card replays: the newest one already judged (v2: the newest executed one, if any) */
86 replay: Proposal | null
87 /** a newer proposal of this card still in its cooldown without a verdict: it is happening on chain right now */
88 live: Proposal | null
89}
90
91/**
92 * @param head the chain head (0 when unknown); a proposal without a verdict counts as live only inside its cooldown
93 */
94export function pickFor(id: LabId, proposals: Iterable<Proposal>, implNames: ReadonlyMap<string, ImplName>, head = 0, receiver?: string): LabPick {
95 const mine = [...proposals].filter((p) => !p.dropped && labIdOf(p, implNames, receiver) === id).sort((a, b) => Number(b.id) - Number(a.id))
96 const judged = mine.filter((p) => replayType(p) !== null)
97 const replay = (id === 'v2' ? judged.find(executed) : undefined) ?? judged[0] ?? null
98 const live =
99 mine.find(
100 (p) =>
101 (!replay || Number(p.id) > Number(replay.id)) &&
102 replayType(p) === null &&
103 p.chain?.status !== 2 &&
104 p.chain?.status !== 3 &&
105 !!p.readyBlock &&
106 (head === 0 || head < p.readyBlock),
107 ) ?? null
108 return { replay, live }
109}
110
111/** The backfill can stop looking for this card: it has a replay (v2: an executed one) */
112export function labSatisfied(id: LabId, pick: LabPick): boolean {
113 return !!pick.replay && (id !== 'v2' || executed(pick.replay))
114}
115
116export interface AutoplayCtx {
117 clean: boolean
118 pick: string | null
119 offline: boolean
120 /** the proposals and their logs are in */
121 ready: boolean
122 /** the viewer already picked a card or a proposal, or pressed a presenter key */
123 interacted: boolean
124 /** a live proposal is in its cooldown */
125 liveBusy: boolean
126}
127/** The first-visit replay may start now */
128export function autoplayWanted(c: AutoplayCtx): boolean {
129 return !c.clean && !c.pick && !c.offline && c.ready && !c.interacted && !c.liveBusy
130}
131/** The first-visit replay can never start any more (it waits out liveBusy and ready, nothing else) */
132export function autoplayRuledOut(c: AutoplayCtx): boolean {
133 return c.clean || !!c.pick || c.offline || c.interacted
134}
135/** While the first-visit replay is still to come, the airlock shows a neutral intro instead of the newest proposal's
136 * final state. A live proposal in its cooldown (known once the data is in) is shown at once instead. */
137export function introWanted(c: AutoplayCtx): boolean {
138 return !autoplayRuledOut(c) && !(c.ready && c.liveBusy)
139}
140
141/** The steps of a picker replay, shown on the playing card: the proposal enters, the CRE judges it, then a veto ejects
142 * it, or a clearance unlocks the inner door, the cooldown is fast-forwarded and the upgrade executes */
143export type LabStage = 'arrive' | 'judge' | 'veto' | 'clear' | 'fast' | 'exec'
144export function stagesFor(type: ReplayType, didExecute: boolean): LabStage[] {
145 if (type === 'ejected') return ['arrive', 'judge', 'veto']
146 return didExecute ? ['arrive', 'judge', 'clear', 'fast', 'exec'] : ['arrive', 'judge', 'clear', 'fast']
147}
148export function stageOf(phase: AirlockPhase, vblock: number, tl: Timeline): LabStage {
149 switch (phase) {
150 case 'idle':
151 return 'arrive'
152 case 'ejected':
153 return 'veto'
154 case 'cleared': {
155 const c = tl.steps.find((s) => s.kind === 'clear')
156 return c && vblock > c.block ? 'fast' : 'clear'
157 }
158 case 'ready':
159 return 'fast'
160 case 'executed':
161 return 'exec'
162 default:
163 return 'judge'
164 }
165}
166
167/** Block timestamp -> "2026-10-20" at the venue (UTC+8), whatever the laptop's time zone */
168export function fmtDay(sec: number | undefined | null): string | null {
169 if (!sec || !Number.isFinite(sec)) return null
170 return new Date((sec + 8 * 3600) * 1000).toISOString().slice(0, 10)
171}
172
173// ---------- page state ----------
174export const lab = {
175 /** stage screens: the bottom row shows the picker or the strip of recent proposals */
176 tab: 'lab' as 'lab' | 'recent',
177 /** the card whose replay was started from the picker (or by the first visit) and its proposal */
178 card: null as LabId | null,
179 pid: null as string | null,
180 /** the viewer picked a card or a proposal, or pressed a presenter key: no first-visit replay after that */
181 interacted: false,
182 autoplay: 'wait' as 'wait' | 'playing' | 'done',
183 /** when the first-visit replay became possible (ms) */
184 readyAt: 0,
185 /** the first-visit replay ended: the picker invites the viewer to pick another card */
186 invite: null as LabId | null,
187 /** cards already played on this page (their button says "watch again") */
188 played: new Set<LabId>(),
189 /** how far the first-visit replay got (0..1) */
190 autoF: 0,
191}
192
193/** The picker card's proposals in the store now */
194export const pickNow = (id: LabId): LabPick => pickFor(id, store.proposals.values(), store.implNames, OFFLINE ? 0 : blocks.latest)
195
196/** The viewer acted (a card, a proposal, a presenter key): the first-visit replay will not start, the invite goes */
197export function noteInteraction() {
198 lab.interacted = true
199 lab.invite = null
200}
201
202/** Play a card: its live proposal if one is on chain right now, else the replay of its newest judged proposal, from
203 * the start. Returns false when the card has nothing to show yet. */
204export function playLab(id: LabId, now = Date.now(), byViewer = true): boolean {
205 if (byViewer) noteInteraction()
206 const pick = pickNow(id)
207 if (pick.live && !OFFLINE) {
208 replay.stop(now, pick.live.id)
209 store.activeId = pick.live.id
210 setManualPick(pick.live.id !== store.newestId())
211 lab.card = null
212 lab.pid = null
213 store.emit()
214 return true
215 }
216 const p = pick.replay
217 if (!p || !historyIn(p.id)) return false
218 // the viewer asked to see how the attack is stopped, so the stages always play; prefers-reduced-motion only tones
219 // the animations down (style.css), it does not skip to the verdict
220 if (!replay.playOne(now, p, false, LAB_PLAY)) return false
221 // when the replay ends, the screen stays on this proposal's real (final) state; a live proposal still takes over
222 store.activeId = p.id
223 setManualPick(p.id !== store.newestId())
224 lab.card = id
225 lab.pid = p.id
226 lab.played.add(id)
227 store.emit()
228 return true
229}
230
231/** The picker card being played right now, with how far it is; null when no picker replay is on screen */
232export function labPlaying(now = Date.now()): { id: LabId; pid: string; f: number; stage: LabStage; stages: LabStage[] } | null {
233 if (!lab.card || !lab.pid || !replay.onDemand) return null
234 const pr = replay.progress(now)
235 const fr = replayFrame(now)
236 if (!pr || !fr || pr.id !== lab.pid || fr.tl.id !== lab.pid) return null
237 const src = store.proposals.get(lab.pid)
238 const stages = stagesFor(fr.tl.type, !!src && executed(src) && fr.tl.steps.some((s) => s.kind === 'executed'))
239 const ph = airlockView(fr.p, fr.vblock).phase
240 return { id: lab.card, pid: lab.pid, f: pr.f, stage: stageOf(ph, fr.vblock, fr.tl), stages }
241}
242
243/** The first-visit replay's data is in: every recent proposal, or already the autoplay card's newest judged one (the
244 * page reads that one first, so the replay can start before the other cards' history arrives) */
245export function autoplayDataIn(): boolean {
246 if (playbackGate.ready) return true
247 const r = pickNow(AUTOPLAY_CARD).replay
248 return !!r && historyIn(r.id)
249}
250
251const autoplayCtx = (): AutoplayCtx => ({ clean: CLEAN, pick: PICK, offline: OFFLINE, ready: autoplayDataIn(), interacted: lab.interacted, liveBusy: liveBusy() })
252
253/** The intro has waited this long for the chain: it says the public RPCs are slow and offers the recorded run */
254export const INTRO_SLOW_MS = 12_000
255/** The intro is still up and the chain has not answered for INTRO_SLOW_MS */
256export function introSlow(now = Date.now()): boolean {
257 return labIntro(now) && !autoplayDataIn() && playbackGate.startedAt > 0 && now - playbackGate.startedAt > INTRO_SLOW_MS
258}
259
260/** The page shows the first-visit intro now: the replay is still to come (data loading, or the short delay after it) */
261export function labIntro(now = Date.now()): boolean {
262 if (lab.autoplay !== 'wait') return false
263 if (lab.readyAt && now - lab.readyAt > AUTOPLAY_GIVE_UP_MS) return false
264 return introWanted(autoplayCtx())
265}
266
267/** Called every frame: starts the first-visit replay when it may, and notices when it ended. Returns whether the
268 * page must redraw. */
269export function tickLab(now = Date.now()): boolean {
270 // a live proposal on screen ends the invite: "That was a real replay…" must never sit next to a live attack (a live
271 // arrival from anywhere else, the CLI or another device, takes the screen without the viewer touching the page)
272 if (lab.invite && isLiveNow(store.active, now)) {
273 lab.invite = null
274 return true
275 }
276 if (lab.autoplay === 'playing') {
277 const lp = labPlaying(now)
278 if (lp && lp.id === lab.card) {
279 lab.autoF = lp.f
280 return false
281 }
282 lab.autoplay = 'done'
283 // invite only after a replay that ran to its end (not one a live proposal cut short)
284 if (!lab.interacted && lab.card && lab.autoF > 0.9) lab.invite = lab.card
285 return true
286 }
287 if (lab.autoplay !== 'wait') return false
288 const ctx = autoplayCtx()
289 if (autoplayRuledOut(ctx)) {
290 lab.autoplay = 'done'
291 return false
292 }
293 if (!ctx.ready) return false
294 if (!lab.readyAt) lab.readyAt = now
295 if (now - lab.readyAt > AUTOPLAY_GIVE_UP_MS) {
296 lab.autoplay = 'done'
297 return false
298 }
299 // a live proposal holds the screen (the intro gives way to it); the replay may still come after its cooldown
300 if (!autoplayWanted(ctx)) return false
301 if (now - lab.readyAt < AUTOPLAY_DELAY_MS) return false
302 // the autoplay card, or the first card that has a judged proposal
303 const order = [AUTOPLAY_CARD, ...LAB_IDS.filter((x) => x !== AUTOPLAY_CARD)]
304 const id = order.find((x) => !!pickNow(x).replay && !pickNow(x).live)
305 if (!id || !playLab(id, now, false)) {
306 lab.autoplay = 'done'
307 return false
308 }
309 lab.autoplay = 'playing'
310 return true
311}
312
313/** The timeline a card would play (tests and the card's step list) */
314export const labTimeline = (id: LabId) => {
315 const p = pickNow(id).replay
316 return p ? buildTimeline(p) : null
317}
318
319/** A proposal on screen is live (not a replay): it arrived live during this visit, or it is in its cooldown now */
320export function isLiveNow(p: Proposal | null, now = Date.now()): boolean {
321 if (!p || OFFLINE) return false
322 const a = store.arrival
323 if (a?.id === p.id && now - a.at < LIVE_LABEL_MS) return true
324 return p.chain?.status === 1 && !!p.readyBlock && blocks.latest > 0 && blocks.latest < p.readyBlock
325}