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. |
| 14 | import { airlockView, type AirlockPhase } from './airlock' |
| 15 | import { blocks } from './chain' |
| 16 | import { DATA } from './chaindata' |
| 17 | import { setManualPick } from './events' |
| 18 | import { historyIn, liveBusy, playbackGate, replay, replayFrame } from './playback' |
| 19 | import { buildTimeline, replayType, type PlayOpts, type ReplayType, type Timeline } from './replay' |
| 20 | import { CLEAN, OFFLINE, PICK } from './runtime' |
| 21 | import { store, type Proposal } from './store' |
| 22 | import type { CardId, ImplName, RuleId } from './types' |
| 23 | |
| 24 | export type LabId = 'evil-mint' | 'evil-owner' | 'evil-fee' | 'evil-basefee' | 'evil-drain' | 'remove-receiver' | 'v2' |
| 25 | |
| 26 | export 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. */ |
| 37 | export 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 | ] |
| 46 | export const LAB_IDS: readonly LabId[] = LAB_CARDS.map((c) => c.id) |
| 47 | export const isLabId = (x: unknown): x is LabId => typeof x === 'string' && (LAB_IDS as readonly string[]).includes(x) |
| 48 | export const labCard = (id: LabId): LabCard => LAB_CARDS.find((c) => c.id === id)! |
| 49 | |
| 50 | /** The card the first visit plays on its own */ |
| 51 | export 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) */ |
| 53 | export 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) */ |
| 55 | export const AUTOPLAY_GIVE_UP_MS = 60_000 |
| 56 | /** A proposal that arrived live during this visit is labelled live this long after its arrival */ |
| 57 | export 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"). */ |
| 63 | export 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. */ |
| 74 | export 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 | |
| 82 | const executed = (p: Proposal) => !!p.execute || p.chain?.status === 3 |
| 83 | |
| 84 | export 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 | */ |
| 94 | export 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) */ |
| 112 | export function labSatisfied(id: LabId, pick: LabPick): boolean { |
| 113 | return !!pick.replay && (id !== 'v2' || executed(pick.replay)) |
| 114 | } |
| 115 | |
| 116 | export 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 */ |
| 128 | export 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) */ |
| 132 | export 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. */ |
| 137 | export 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 */ |
| 143 | export type LabStage = 'arrive' | 'judge' | 'veto' | 'clear' | 'fast' | 'exec' |
| 144 | export 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 | } |
| 148 | export 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 */ |
| 168 | export 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 ---------- |
| 174 | export 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 */ |
| 194 | export 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 */ |
| 197 | export 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. */ |
| 204 | export 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 */ |
| 232 | export 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) */ |
| 245 | export function autoplayDataIn(): boolean { |
| 246 | if (playbackGate.ready) return true |
| 247 | const r = pickNow(AUTOPLAY_CARD).replay |
| 248 | return !!r && historyIn(r.id) |
| 249 | } |
| 250 | |
| 251 | const 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 */ |
| 254 | export const INTRO_SLOW_MS = 12_000 |
| 255 | /** The intro is still up and the chain has not answered for INTRO_SLOW_MS */ |
| 256 | export 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) */ |
| 261 | export 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. */ |
| 269 | export 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) */ |
| 314 | export 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 */ |
| 320 | export 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 | } |