web/test/airlock-selftest.ts1272 lines · 100270 bytes
| 1 | // Live-mode self-test (?mode=airlock): no browser and no network. JSON-RPC is answered by the fake Sepolia below |
| 2 | // (eth_getLogs refuses ranges over 50 blocks, like 1rpc); WebSocket is a stand-in that never connects. |
| 3 | // The deployment is test/fixtures/chain-data.json (see test/rolldown.config.mjs). npm run selftest |
| 4 | import { NoSocket } from './airlock-stub' |
| 5 | import { installDom } from './domstub' |
| 6 | import { execFileSync } from 'node:child_process' |
| 7 | import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 8 | import { tmpdir } from 'node:os' |
| 9 | import { join } from 'node:path' |
| 10 | import { |
| 11 | decodeFunctionData, |
| 12 | encodeAbiParameters, |
| 13 | encodeEventTopics, |
| 14 | encodeFunctionResult, |
| 15 | keccak256, |
| 16 | toEventSelector, |
| 17 | toFunctionSelector, |
| 18 | toHex, |
| 19 | type Abi, |
| 20 | type AbiEvent, |
| 21 | type AbiFunction, |
| 22 | type Hex, |
| 23 | } from 'viem' |
| 24 | import { ADMIN_ABI, RECEIVER_ABI } from '../src/abi' |
| 25 | import { airlockView, fmtClock, fmtDur, startTitle } from '../src/airlock' |
| 26 | import { blocks } from '../src/chain' |
| 27 | import { DATA, PENDING, type RawLog } from '../src/chaindata' |
| 28 | import { backfillLab, chainHealth, loadOrder, loadPicked, startChainWatch, TOPICS } from '../src/chainwatch' |
| 29 | import { CONFIG } from '../src/config' |
| 30 | import { HELP_ROWS, keyAction, labIndexOfKey, stepId, type Action } from '../src/controls' |
| 31 | import { explorerUrl, fmtEth, fmtGasPrice, realWorkflowId } from '../src/derive' |
| 32 | import { blockTs, handleLog, pickActive, resetSeen, setManualPick } from '../src/events' |
| 33 | import { DICT, setLang, t, type Lang } from '../src/i18n' |
| 34 | import { historyIn, judgeProgress, playbackGate, replay, tickReplay } from '../src/playback' |
| 35 | import { AutoReplay, buildTimeline, snapshotAt } from '../src/replay' |
| 36 | import { |
| 37 | AUTOPLAY_CARD, |
| 38 | AUTOPLAY_DELAY_MS, |
| 39 | autoplayDataIn, |
| 40 | autoplayRuledOut, |
| 41 | autoplayWanted, |
| 42 | fmtDay, |
| 43 | INTRO_SLOW_MS, |
| 44 | introSlow, |
| 45 | introWanted, |
| 46 | lab, |
| 47 | LAB_CARDS, |
| 48 | LAB_IDS, |
| 49 | LAB_PLAY, |
| 50 | labIdOf, |
| 51 | labIntro, |
| 52 | labPlaying, |
| 53 | labSatisfied, |
| 54 | pickFor, |
| 55 | pickNow, |
| 56 | playLab, |
| 57 | stageOf, |
| 58 | stagesFor, |
| 59 | tickLab, |
| 60 | type AutoplayCtx, |
| 61 | type LabId, |
| 62 | } from '../src/lab' |
| 63 | import { |
| 64 | launchCard, |
| 65 | launchStage, |
| 66 | loadOperator, |
| 67 | mayOperate, |
| 68 | op, |
| 69 | OP_DISABLED, |
| 70 | operatorVisible, |
| 71 | parseLaunchResponse, |
| 72 | parseOperatorInfo, |
| 73 | saveToken, |
| 74 | takeTokenFromUrl, |
| 75 | tokenFromUrl, |
| 76 | } from '../src/operator-client' |
| 77 | import { CHAIN, chainWithRpc, CLEAN, FORK, GUARDS, isLoopback, MODE, OFFLINE, parseClean, parsePick, parseRpcOverride, pickMode, rpcOverrideOf } from '../src/runtime' |
| 78 | import { newProposal, store, type Proposal } from '../src/store' |
| 79 | import { ruleHashOf } from '../src/types' |
| 80 | import { launchErrorText } from '../src/lab-ui' |
| 81 | import { identOf, momentsFor, phrases, pipelineOf, render, STAGE_QUERY, ui } from '../src/ui' |
| 82 | import { findBlocked, NAMES_NOTE, scanWeb } from './scan' |
| 83 | |
| 84 | let fails = 0 |
| 85 | function ok(cond: unknown, msg: string) { |
| 86 | console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`) |
| 87 | if (!cond) fails++ |
| 88 | } |
| 89 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) |
| 90 | async function until(fn: () => boolean, ms: number) { |
| 91 | const t0 = Date.now() |
| 92 | while (!fn() && Date.now() - t0 < ms) await sleep(20) |
| 93 | return fn() |
| 94 | } |
| 95 | const CJK = /[㐀-鿿 -〿-]/ |
| 96 | /** Simplified-only characters that must never show up in zh-TW */ |
| 97 | const SIMPLIFIED_ONLY = /[区块链测试网报这个为发时间实现评题机连线执务应总转账没权变态错误载读据击两导点让说计规则节预开关显设证资产处数长额签码详图际断续单传备换认话该调钥钱闭难页验类给满离线异议级气闸门舱弹还后录确锁扫]/ |
| 98 | const WEB = process.cwd() |
| 99 | const REPO = join(WEB, '..') |
| 100 | |
| 101 | // ---------- 1. modes and Sepolia settings ---------- |
| 102 | ok(MODE === 'airlock' && !OFFLINE && !FORK, 'default mode is the live airlock (no fork, not offline)') |
| 103 | ok(['offline', 'airlock'].every((m) => pickMode(m) === m) && ['chain', 'testnet', 'fake', 'replay', 'x', '', null].every((m) => pickMode(m as string | null) === 'airlock'), 'only airlock and offline exist; any other ?mode= falls back to airlock') |
| 104 | ok(GUARDS.join(',') === 'cre,breakglass', `guard cards: Chainlink CRE + human break-glass (${GUARDS.join(',')})`) |
| 105 | ok(CHAIN.chainId === 11155111 && CHAIN.http.length === 3 && CHAIN.http.every((u) => u.startsWith('https://')), `Sepolia 11155111 over ${CHAIN.http.length} HTTPS endpoints`) |
| 106 | ok(CHAIN.ws.length >= 1 && CHAIN.ws.every((u) => u.startsWith('wss://')) && !CHAIN.ws.some((u) => u.includes('1rpc')), 'WS only on endpoints that serve it (1rpc has none)') |
| 107 | ok(CHAIN.pollMs >= 1000 && CHAIN.logRange <= 50, `poll every ${CHAIN.pollMs} ms while the WS is down; eth_getLogs ranges <= ${CHAIN.logRange} blocks`) |
| 108 | const TX = ('0x' + 'ab'.repeat(32)) as Hex |
| 109 | ok(explorerUrl(CHAIN.explorerTx, TX) === `https://sepolia.etherscan.io/tx/${TX}`, 'tx links go to sepolia.etherscan.io') |
| 110 | ok(parseRpcOverride('http://127.0.0.1:19621/') === 'http://127.0.0.1:19621' && ['javascript:alert(1)', 'ws://127.0.0.1:8545', '', 'http://', 'http://a b'].every((x) => parseRpcOverride(x) === null), '?rpc= accepts http(s) URLs only') |
| 111 | ok(isLoopback('http://127.0.0.1:19621') && isLoopback('http://localhost:8545') && isLoopback('http://[::1]:8545') && !isLoopback('https://sepolia.drpc.org') && !isLoopback('http://127.0.0.1.evil.example'), 'loopback detection') |
| 112 | { |
| 113 | const fork = chainWithRpc(CONFIG.sepolia, 'http://127.0.0.1:19621') |
| 114 | ok(fork.http.join() === 'http://127.0.0.1:19621' && fork.ws.join() === 'ws://127.0.0.1:19621' && fork.explorerTx === null && fork.sourcifyApi === null, 'local fork: one endpoint (HTTP + WS on the same port), no explorer, no Sourcify') |
| 115 | // ?rpc= is for a local fork only: a link to the public site must never make it read (and call "Live · Ethereum |
| 116 | // Sepolia") someone else's JSON-RPC. FORK is exactly "rpcOverrideOf(?rpc=) !== null". |
| 117 | ok( |
| 118 | rpcOverrideOf('http://127.0.0.1:19621/') === 'http://127.0.0.1:19621' && rpcOverrideOf('http://localhost:8545') === 'http://localhost:8545' && |
| 119 | ['https://evil.example', 'https://sepolia.drpc.org', 'http://127.0.0.1.evil.example:8545', 'javascript:alert(1)', '', null].every((x) => rpcOverrideOf(x) === null) && |
| 120 | rpcOverrideOf('http://127.0.0.1:19621', true) === null, |
| 121 | '?rpc= override: loopback addresses only; any other host (or offline mode) is ignored, so FORK stays off', |
| 122 | ) |
| 123 | ok( |
| 124 | CHAIN.historyHttp.length === 4 && CHAIN.historyHttp.every((u) => u.startsWith('https://')) && CHAIN.historyHttp[0] === 'https://sepolia.drpc.org' && CHAIN.historyHttp[3] === 'https://ethereum-sepolia-rpc.publicnode.com' && new Set(CHAIN.historyHttp).size === 4, |
| 125 | `proposal history from ${CHAIN.historyHttp.length} endpoints: drpc first, publicnode (prunes old logs) last`, |
| 126 | ) |
| 127 | const evil = chainWithRpc(CONFIG.sepolia, rpcOverrideOf('https://evil.example')) |
| 128 | ok( |
| 129 | evil.http.length === 3 && evil.http.join() === CONFIG.sepolia.http.join() && evil.ws.join() === CONFIG.sepolia.ws.join() && evil.historyHttp.join() === CONFIG.sepolia.historyHttp.join() && !evil.http.some((u) => u.includes('evil')), |
| 130 | '?rpc=https://evil.example: the page keeps reading the 3 Sepolia endpoints', |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | // ---------- 2. ABI against the forge artifacts (FOUNDRY_PROFILE=eth forge build) ---------- |
| 135 | { |
| 136 | const adminOut = join(REPO, 'out', 'AirlockAdmin.sol', 'AirlockAdmin.json') |
| 137 | const rxOut = join(REPO, 'out', 'AirlockReceiver.sol', 'AirlockReceiver.json') |
| 138 | const evTopic = (abi: readonly unknown[], name: string) => { |
| 139 | const e = (abi as AbiEvent[]).find((x) => x.type === 'event' && x.name === name) |
| 140 | return e ? toEventSelector(e) : null |
| 141 | } |
| 142 | const fnSel = (abi: readonly unknown[], name: string) => { |
| 143 | const f = (abi as AbiFunction[]).find((x) => x.type === 'function' && x.name === name) |
| 144 | return f ? toFunctionSelector(f) : null |
| 145 | } |
| 146 | ok(evTopic(ADMIN_ABI, 'Cleared') === '0xdfffc913c95f0f11ab761375668bb20cc87c4079b9cc7b8b59d7a1431c0c7b42', 'Cleared topic matches the contracts interface') |
| 147 | ok(evTopic(RECEIVER_ABI, 'Verdict') === '0x76ccdd46dc53d2b7f83e2731ac9dad444e191b37376383f1307798c1c54353f3', 'Verdict topic matches the contracts interface') |
| 148 | ok(fnSel(ADMIN_ABI, 'clearanceOf') === '0x6507f7cb', 'clearanceOf selector 0x6507f7cb') |
| 149 | if (existsSync(adminOut) && existsSync(rxOut)) { |
| 150 | const A = JSON.parse(readFileSync(adminOut, 'utf8')).abi as Abi |
| 151 | const R = JSON.parse(readFileSync(rxOut, 'utf8')).abi as Abi |
| 152 | const evs = ['Proposed', 'Vetoed', 'Cleared', 'Executed', 'ReasonAttached'] |
| 153 | ok(evs.every((n) => evTopic(A, n) !== null && evTopic(A, n) === evTopic(ADMIN_ABI, n)), `AirlockAdmin events match out/ (${evs.join(', ')})`) |
| 154 | ok(evTopic(R, 'Verdict') === evTopic(RECEIVER_ABI, 'Verdict'), 'AirlockReceiver.Verdict matches out/') |
| 155 | const fns = ['getProposal', 'clearanceOf', 'nextId', 'delayBlocks'] |
| 156 | ok(fns.every((n) => fnSel(A, n) !== null && fnSel(A, n) === fnSel(ADMIN_ABI, n)), `AirlockAdmin views match out/ (${fns.join(', ')})`) |
| 157 | const comps = (abi: Abi) => ((abi as AbiFunction[]).find((x) => x.type === 'function' && x.name === 'getProposal')!.outputs[0] as { components: { name: string; type: string }[] }).components.map((c) => `${c.type} ${c.name}`).join(',') |
| 158 | ok(comps(A) === comps(ADMIN_ABI as Abi), 'getProposal returns the same 10-field struct') |
| 159 | // the recorder script keeps its own copy of the signatures |
| 160 | const rec = readFileSync(join(WEB, 'scripts', 'record-run.mjs'), 'utf8') |
| 161 | const sigs = readFileSync(join(WEB, 'src', 'abi.ts'), 'utf8').match(/'(?:event|function|struct) [^']+'/g) ?? [] |
| 162 | ok(sigs.length >= 9 && sigs.filter((s) => !s.includes('delayBlocks')).every((s) => rec.includes(s)), 'scripts/record-run.mjs uses the same signatures as src/abi.ts') |
| 163 | } else console.log('skip ABI vs out/: run FOUNDRY_PROFILE=eth forge build first') |
| 164 | } |
| 165 | |
| 166 | // ---------- 3. scripts/sync-sepolia-data.mjs ---------- |
| 167 | { |
| 168 | const dir = mkdtempSync(join(tmpdir(), 'airlock-sync-')) |
| 169 | const dep = JSON.parse(readFileSync(join(WEB, 'test', 'fixtures', 'deployment.json'), 'utf8')) |
| 170 | const depFile = join(dir, 'deployment.json') |
| 171 | const out = join(dir, 'out.json') |
| 172 | const run = (d: unknown, args: string[] = []) => { |
| 173 | if (d !== null) writeFileSync(depFile, JSON.stringify(d)) |
| 174 | try { |
| 175 | execFileSync(process.execPath, [join(WEB, 'scripts', 'sync-sepolia-data.mjs'), d === null ? join(dir, 'missing.json') : depFile, '--out', out, ...args], { stdio: 'pipe' }) |
| 176 | return true |
| 177 | } catch { |
| 178 | return false |
| 179 | } |
| 180 | } |
| 181 | ok(run(dep), 'reads a DeployEth output (airlockAdmin, receiver, emergencyGuardian, approver, ...)') |
| 182 | const j = JSON.parse(readFileSync(out, 'utf8')) |
| 183 | ok(j.pending === false && j.airlockAdmin === dep.airlockAdmin && j.receiver === dep.receiver && j.emergencyGuardian === dep.emergencyGuardian && j.approver === dep.approver && j.startBlock === 900 && j.delayBlocks === 25, 'data file: addresses, startBlock, cooldown') |
| 184 | ok(['v1', 'v2', 'evilMint', 'evilOwner', 'evilFee', 'evilBaseFee', 'evilDrain', 'paused'].every((k) => j.impls[k] === dep[k]), 'data file: all eight implementations (EvilBaseFee and EvilDrain included)') |
| 185 | ok(JSON.stringify(j) === JSON.stringify(DATA), 'the fixture the page reads is exactly what the script writes') |
| 186 | ok(findBlocked(readFileSync(out, 'utf8')).length === 0, 'no local path in the data file') |
| 187 | ok(!run({ ...dep, chainId: 1 }), 'refuses a chain that is not Sepolia') |
| 188 | ok(!run({ ...dep, approver: dep.owner }), 'refuses approver == owner') |
| 189 | ok(!run({ ...dep, emergencyGuardian: dep.approver }), 'refuses approver == emergency guardian') |
| 190 | ok(!run({ ...dep, receiver: undefined }), 'refuses a missing receiver') |
| 191 | const { airlockAdmin: _drop, ...older } = dep |
| 192 | ok(run({ ...older, guardedAdmin: dep.airlockAdmin }) && JSON.parse(readFileSync(out, 'utf8')).pending === true, 'a deployment file without an airlockAdmin key gives the placeholder') |
| 193 | ok(!run({ ...older, guardedAdmin: dep.airlockAdmin }, ['--require']), '--require fails on the placeholder') |
| 194 | ok(run(null) && JSON.parse(readFileSync(out, 'utf8')).pending === true, 'no deployment file: placeholder (pending: true)') |
| 195 | rmSync(dir, { recursive: true, force: true }) |
| 196 | const shipped = JSON.parse(readFileSync(join(WEB, 'src', 'chain-data-sepolia.json'), 'utf8')) |
| 197 | ok(shipped.pending === true || (shipped.chainId === 11155111 && /^0x[0-9a-fA-F]{40}$/.test(shipped.airlockAdmin) && /^0x[0-9a-fA-F]{40}$/.test(shipped.receiver)), `shipped src/chain-data-sepolia.json is ${shipped.pending ? 'the placeholder' : 'a full deployment'}`) |
| 198 | } |
| 199 | |
| 200 | // ---------- 4. airlockView (pure) ---------- |
| 201 | const H = (n: number) => ('0x' + n.toString(16).padStart(64, '0')) as Hex |
| 202 | const base = (): Proposal => ({ ...newProposal('9'), target: '0x01', codehash: '0x02', proposeBlock: 100, readyBlock: 125 }) |
| 203 | const RX = DATA.receiver |
| 204 | { |
| 205 | ok(airlockView(null, 0).phase === 'idle', 'no proposal: idle') |
| 206 | const ch = airlockView(base(), 110) |
| 207 | ok(ch.phase === 'chamber' && ch.total === 25 && ch.left === 15 && ch.leftSec === 180 && ch.pct === 40 && !ch.cleared, `cooling, not cleared: chamber, ${ch.left} blocks ≈ ${ch.leftSec} s left, ${ch.pct}%`) |
| 208 | ok(airlockView(base(), 125).phase === 'waiting' && airlockView(base(), 130).over === 5, 'cooldown over without a clearance: waiting (not executable)') |
| 209 | const cl = base() |
| 210 | cl.clearance = { evidenceHash: H(0xc1), clearer: RX, by: 'cre', tx: H(103), block: 103 } |
| 211 | cl.guards.cre = { verdict: 'pass', tx: H(103), block: 103, evidenceHash: H(0xc1), workflowId: H(0xf1) } |
| 212 | const ts = new Map([ |
| 213 | [100, 1_000], |
| 214 | [103, 1_036], |
| 215 | ]) |
| 216 | const v1 = airlockView(cl, 110, (n) => ts.get(n)) |
| 217 | ok(v1.phase === 'cleared' && v1.clear?.by === 'cre' && v1.clear.afterBlocks === 3 && v1.clear.afterSec === 36 && v1.clear.secFromTs && v1.clear.beforeReady === 22, 'cleared by the CRE 3 blocks · 36 s (block timestamps) after the proposal; the door still waits for the cooldown') |
| 218 | ok(v1.clear?.evidenceHash === H(0xc1) && v1.clear.workflowId === H(0xf1) && v1.clear.tx === H(103), 'clearance carries evidence hash, workflowId and tx') |
| 219 | ok(airlockView(cl, 125).phase === 'ready' && airlockView(cl, 125).pct === 100, 'cleared and cooldown over: ready (executable)') |
| 220 | const firstHash = { ...cl, chain: { status: 1, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc0) } } |
| 221 | ok(airlockView(firstHash, 110).clear?.evidenceHash === H(0xc0), 'clearanceOf (the first hash, kept on chain) wins over a later Cleared log') |
| 222 | const ex = { ...cl, execute: { tx: H(130), block: 130 }, chain: { status: 3, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc1) } } |
| 223 | ok(airlockView(ex, 140).phase === 'executed' && airlockView(ex, 140).pct === 100, 'executed: inner door open') |
| 224 | const late = base() |
| 225 | late.chain = { status: 1, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc2) } |
| 226 | ok(airlockView(late, 110).phase === 'cleared' && airlockView(late, 110).clear === null && airlockView(late, 110).cleared, 'clearanceOf without a log in reach still counts as cleared') |
| 227 | const ej = base() |
| 228 | ej.guards.cre = { verdict: 'veto', rule: 'R1_TOTAL_SUPPLY', ruleHash: ruleHashOf('R1_TOTAL_SUPPLY'), tx: H(102), block: 102, evidenceHash: H(0xe1), workflowId: H(0xf1) } |
| 229 | const ve = airlockView(ej, 104, (n) => (n === 100 ? 1000 : n === 102 ? 1024 : undefined)) |
| 230 | ok(ve.phase === 'ejected' && ve.veto?.by === 'cre' && ve.veto.afterBlocks === 2 && ve.veto.toInner === 23 && ve.veto.afterSec === 24 && ve.pct === 8, 'CRE veto: ejected 2 blocks · 24 s after the proposal, 23 blocks short of the inner door') |
| 231 | ok(airlockView(ej, 104).veto?.secFromTs === false && airlockView(ej, 104).veto?.afterSec === 24, 'without timestamps the seconds are estimated (12 s/block) and say so') |
| 232 | const vetoAfterClear = { ...cl, guards: { ...cl.guards, breakglass: { verdict: 'veto' as const, rule: 'R2_OWNER', tx: H(108), block: 108 } } } |
| 233 | ok(airlockView(vetoAfterClear, 130).phase === 'ejected' && airlockView(vetoAfterClear, 130).veto?.by === 'breakglass', 'a guardian veto still wins over a clearance') |
| 234 | const appr = base() |
| 235 | appr.clearance = { evidenceHash: H(0xa1), clearer: DATA.approver, by: 'approver', tx: H(105), block: 105 } |
| 236 | ok(airlockView(appr, 126).phase === 'ready' && airlockView(appr, 126).clear?.by === 'approver', 'clearance by the approver by hand also opens the door') |
| 237 | setLang('zh-CN', false) |
| 238 | ok(fmtDur(24) === '24 秒' && fmtDur(300) === '5 分钟' && fmtDur(276) === '4 分 36 秒', 'zh-CN durations') |
| 239 | setLang('en', false) |
| 240 | ok(fmtDur(24) === '24 s' && fmtDur(276) === '4 min 36 s', 'en durations') |
| 241 | ok(t('s.after', { n: 1 }) === '+1 block' && t('s.after', { n: 2 }) === '+2 blocks', 'en plural: +1 block, +2 blocks') |
| 242 | ok(fmtClock(300) === '5:00' && fmtClock(276) === '4:36' && fmtClock(11.2) === '0:12' && fmtClock(-3) === '0:00', 'cooldown clock m:ss') |
| 243 | setLang('zh-CN', false) |
| 244 | ok(fmtEth(3_750_000n) === '3,750,000 wei' && fmtEth(2_500_000_000_000n) === '2,500 gwei' && fmtGasPrice(15n) === '15 wei', 'fee formats (Sepolia gas prices can be a few wei)') |
| 245 | } |
| 246 | |
| 247 | // ---------- 5. playback engine (replay.ts) ---------- |
| 248 | { |
| 249 | const p = base() |
| 250 | p.proposeTx = H(100) |
| 251 | p.clearance = { evidenceHash: H(0xc1), clearer: RX, by: 'cre', tx: H(103), block: 103 } |
| 252 | p.guards.cre = { verdict: 'pass', tx: H(103), block: 103 } |
| 253 | p.execute = { tx: H(127), block: 127 } |
| 254 | p.chain = { status: 3, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc1) } |
| 255 | const tl = buildTimeline(p)! |
| 256 | ok(tl.type === 'cleared' && tl.steps.map((s) => s.kind).join(',') === 'proposed,clear,ready,executed' && tl.end === 130, `cleared timeline: ${tl.steps.map((s) => `${s.kind}@${s.block}`).join(' ')}`) |
| 257 | const phaseAt = (src: Proposal, v: number) => airlockView(snapshotAt(src, buildTimeline(src)!, v), v).phase |
| 258 | ok([99, 101, 104, 125, 128].map((v) => phaseAt(p, v)).join(',') === 'idle,chamber,cleared,ready,executed', 'played back: idle → chamber → cleared → ready → executed') |
| 259 | const lateClear = { ...p, clearance: { ...p.clearance!, block: 131, tx: H(131) }, guards: { ...p.guards, cre: { verdict: 'pass' as const, tx: H(131), block: 131 } }, execute: { tx: H(133), block: 133 } } |
| 260 | ok([110, 128, 132, 134].map((v) => phaseAt(lateClear, v)).join(',') === 'chamber,waiting,ready,executed', 'a clearance after the cooldown: chamber → waiting → ready → executed') |
| 261 | const ej = base() |
| 262 | ej.guards.cre = { verdict: 'veto', rule: 'R9_NEW_OPCODES', tx: H(102), block: 102 } |
| 263 | ej.reason = { tx: H(102), hash: H(0xe9), block: 102 } |
| 264 | ej.chain = { status: 2, vetoCount: 1, proposer: '0x1', clearanceOf: null } |
| 265 | ok([101, 102].map((v) => phaseAt(ej, v)).join(',') === 'chamber,ejected', 'vetoed timeline: chamber → ejected') |
| 266 | const q = { ...p, id: '12' } |
| 267 | const r = { ...ej, id: '7' } |
| 268 | const engine = new AutoReplay(0, { msPerBlock: 100, firstDelayMs: 0, idleMs: 1000, gapMs: 0, reducedHoldMs: 100 }) |
| 269 | const all = [q, r] |
| 270 | const ctx = { proposals: all, get: (id: string) => all.find((x) => x.id === id), reduced: false, liveBusy: false } |
| 271 | engine.tick(0, ctx) |
| 272 | const first = engine.frame(0, ctx.get)?.tl.id |
| 273 | engine.tick(100_000, ctx) |
| 274 | const second = engine.frame(100_000, ctx.get)?.tl.id |
| 275 | ok(first === '7' && second === '12', `plays proposals in id order and loops (${first} then ${second})`) |
| 276 | } |
| 277 | |
| 278 | // ---------- 5b. playback for the stage: short ejected timelines, holds on the moments, Space replays one ---------- |
| 279 | { |
| 280 | const ej = base() |
| 281 | ej.guards.cre = { verdict: 'veto', rule: 'R1_TOTAL_SUPPLY', tx: H(101), block: 101 } |
| 282 | ej.chain = { status: 2, vetoCount: 1, proposer: '0x1', clearanceOf: null } |
| 283 | const tl = buildTimeline(ej)! |
| 284 | ok(tl.type === 'ejected' && tl.end === 106 && !tl.steps.some((x) => x.kind === 'ready'), `an ejected timeline ends 5 blocks after the veto, without the rest of the cooldown (end ${tl.end})`) |
| 285 | const ok2 = { ...base(), id: '2' } |
| 286 | ok2.clearance = { evidenceHash: H(0xc1), clearer: RX, by: 'cre', tx: H(102), block: 102 } |
| 287 | ok2.guards.cre = { verdict: 'pass', tx: H(102), block: 102 } |
| 288 | ok2.execute = { tx: H(126), block: 126 } |
| 289 | ok2.chain = { status: 3, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc1) } |
| 290 | const all = [{ ...ej, id: '1' }, ok2] |
| 291 | const get = (id: string) => all.find((x) => x.id === id) |
| 292 | const ctx = { proposals: all, get, reduced: false, liveBusy: false } |
| 293 | const e = new AutoReplay(0, { msPerBlock: 100, dwellMs: { proposed: 1000, verdict: 2000, executed: 1000 }, firstDelayMs: 0, idleMs: 5000, gapMs: 0, reducedHoldMs: 100 }) |
| 294 | e.paused = true |
| 295 | ok(!e.tick(0, ctx) && e.frame(0, get) === null, 'a paused loop plays nothing on its own') |
| 296 | ok(e.playOne(0, ok2, false), 'Space: one proposal plays while the loop is paused') |
| 297 | // lead 2 blocks (98, 99) = 200 ms; hold 1000 ms on the proposal block 100; verdict at 102 holds 2000 ms |
| 298 | const vb = (ms: number) => (e.tick(ms, ctx), e.frame(ms, get)?.vblock) |
| 299 | ok(vb(50) === 98 && vb(150) === 99 && vb(250) === 100 && vb(1150) === 100 && vb(1250) === 100 && vb(1350) === 101 && vb(1500) === 102 && vb(3300) === 102 && vb(3550) === 103, 'the clock holds on the proposal and on the verdict') |
| 300 | ok(e.frame(3550, get)?.paused === false && e.frame(3550, get)?.tl.id === '2', 'a Space replay is not shown as paused') |
| 301 | let t = 3550 |
| 302 | while (e.frame(t, get) && t < 60_000) e.tick((t += 100), ctx) |
| 303 | ok(t < 60_000 && e.frame(t, get) === null && !e.tick(t + 100_000, ctx), 'after a Space replay the paused loop stays paused (no next proposal)') |
| 304 | const o = new AutoReplay(0, { msPerBlock: 100, firstDelayMs: 0, idleMs: 5000, gapMs: 0, reducedHoldMs: 100 }) |
| 305 | o.startAt('2') |
| 306 | o.tick(0, ctx) |
| 307 | ok(o.frame(0, get)?.tl.id === '2', '?p= in offline mode: the loop starts with that proposal') |
| 308 | } |
| 309 | |
| 310 | // ---------- 6. live read: a fake Sepolia (AirlockAdmin + AirlockReceiver logs, receipts, timestamps) ---------- |
| 311 | const ADMIN = DATA.airlockAdmin as Hex |
| 312 | const RECV = DATA.receiver as Hex |
| 313 | const OWNER = DATA.owner as Hex |
| 314 | const IMPL = DATA.impls as Record<string, Hex> |
| 315 | const H32 = (s: string) => keccak256(toHex(s)) |
| 316 | const EV1 = H32('evidence #1') |
| 317 | const EV2 = H32('evidence #2') |
| 318 | const WF = H32('workflow') |
| 319 | const ZERO32 = ('0x' + '00'.repeat(32)) as Hex |
| 320 | let HEAD = 1200 |
| 321 | let NEXT = 4 |
| 322 | |
| 323 | function mkLog(abi: Abi, address: Hex, eventName: string, args: Record<string, unknown>, block: number, tx: Hex, logIndex: number): RawLog { |
| 324 | const ev = (abi as AbiEvent[]).find((x) => x.type === 'event' && x.name === eventName)! |
| 325 | const topics = encodeEventTopics({ abi: [ev], eventName, args } as never) as Hex[] |
| 326 | const nonIdx = ev.inputs.filter((i) => !i.indexed) |
| 327 | const data = encodeAbiParameters(nonIdx as never, nonIdx.map((i) => args[i.name!]) as never) |
| 328 | return { address, topics, data, blockNumber: toHex(block), transactionHash: tx, logIndex: toHex(logIndex) } |
| 329 | } |
| 330 | type ChainProp = { kind: number; status: number; proposer: Hex; target: Hex; value: bigint; codehash: Hex; dataHash: Hex; proposedBlock: bigint; readyBlock: bigint; vetoCount: bigint } |
| 331 | const prop = (target: Hex, status: number, pb: number, vetoCount = 0n): ChainProp => ({ kind: 0, status, proposer: OWNER, target, value: 0n, codehash: H32(`code ${target}`), dataHash: ZERO32, proposedBlock: BigInt(pb), readyBlock: BigInt(pb + 25), vetoCount }) |
| 332 | const PROPS = new Map<number, ChainProp>([ |
| 333 | [1, prop(IMPL.evilMint, 2, 1000, 1n)], |
| 334 | [2, prop(IMPL.v2, 3, 1100)], |
| 335 | [3, prop(IMPL.evilBaseFee, 1, 1150)], |
| 336 | ]) |
| 337 | const CLEAR = new Map<number, Hex>([[2, EV2]]) |
| 338 | const T = (s: string) => H32(`tx ${s}`) |
| 339 | const LOGS: RawLog[] = [ |
| 340 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 1n, kind: 0, target: IMPL.evilMint, value: 0n, data: '0x', codehash: H32('c1'), readyBlock: 1025n }, 1000, T('p1'), 0), |
| 341 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 1n, guardian: RECV, ruleHash: ruleHashOf('R1_TOTAL_SUPPLY'), vetoCount: 1n }, 1002, T('v1'), 0), |
| 342 | mkLog(ADMIN_ABI, ADMIN, 'ReasonAttached', { id: 1n, guardian: RECV, reportHash: EV1 }, 1002, T('v1'), 1), |
| 343 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 1n, action: 0, ruleHash: ruleHashOf('R1_TOTAL_SUPPLY'), evidenceHash: EV1, workflowId: WF }, 1002, T('v1'), 2), |
| 344 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 2n, kind: 0, target: IMPL.v2, value: 0n, data: '0x', codehash: H32('c2'), readyBlock: 1125n }, 1100, T('p2'), 0), |
| 345 | mkLog(ADMIN_ABI, ADMIN, 'Cleared', { id: 2n, clearer: RECV, evidenceHash: EV2 }, 1103, T('c2'), 0), |
| 346 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 2n, action: 1, ruleHash: ZERO32, evidenceHash: EV2, workflowId: WF }, 1103, T('c2'), 1), |
| 347 | // Executed 80 blocks after the proposal: only the second 50-block getLogs range finds it |
| 348 | mkLog(ADMIN_ABI, ADMIN, 'Executed', { id: 2n, executor: OWNER }, 1180, T('e2'), 0), |
| 349 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 3n, kind: 0, target: IMPL.evilBaseFee, value: 0n, data: '0x', codehash: H32('c3'), readyBlock: 1175n }, 1150, T('p3'), 0), |
| 350 | ] |
| 351 | const blockTime = (n: number) => 1_760_000_000 + (n - 1000) * 12 |
| 352 | const rpcCalls: { url: string; method: string; params: unknown[] }[] = [] |
| 353 | const asList = (x: unknown) => (Array.isArray(x) ? x : [x]).map((s) => String(s).toLowerCase()) |
| 354 | globalThis.fetch = (async (url: string, init?: RequestInit) => { |
| 355 | const req = JSON.parse(String(init?.body)) as { id: number; method: string; params: unknown[] } |
| 356 | rpcCalls.push({ url: String(url), method: req.method, params: req.params }) |
| 357 | const reply = (body: unknown) => new Response(JSON.stringify({ jsonrpc: '2.0', id: req.id, ...(body as object) }), { status: 200, headers: { 'content-type': 'application/json' } }) |
| 358 | const result = (r: unknown) => reply({ result: r }) |
| 359 | switch (req.method) { |
| 360 | case 'eth_blockNumber': |
| 361 | return result(toHex(HEAD)) |
| 362 | case 'eth_getBlockByNumber': { |
| 363 | const n = Number(BigInt(String(req.params[0]))) |
| 364 | return result({ number: toHex(n), timestamp: toHex(blockTime(n)) }) |
| 365 | } |
| 366 | case 'eth_getTransactionReceipt': { |
| 367 | const l = LOGS.find((x) => x.transactionHash === req.params[0]) |
| 368 | return result(l ? { blockNumber: l.blockNumber, status: '0x1', gasUsed: toHex(250000), effectiveGasPrice: toHex(15) } : null) |
| 369 | } |
| 370 | case 'eth_call': { |
| 371 | const call = req.params[0] as { to: string; data: Hex } |
| 372 | if (call.to.toLowerCase() !== ADMIN.toLowerCase()) return reply({ error: { code: -32000, message: 'unexpected eth_call target' } }) |
| 373 | const d = decodeFunctionData({ abi: ADMIN_ABI, data: call.data }) as { functionName: string; args?: readonly unknown[] } |
| 374 | const id = Number(d.args?.[0] ?? 0) |
| 375 | if (d.functionName === 'nextId') return result(encodeFunctionResult({ abi: ADMIN_ABI, functionName: 'nextId', result: BigInt(NEXT) } as never)) |
| 376 | if (d.functionName === 'clearanceOf') return result(encodeFunctionResult({ abi: ADMIN_ABI, functionName: 'clearanceOf', result: CLEAR.get(id) ?? ZERO32 } as never)) |
| 377 | if (d.functionName === 'getProposal') return result(encodeFunctionResult({ abi: ADMIN_ABI, functionName: 'getProposal', result: PROPS.get(id) ?? prop(('0x' + '00'.repeat(20)) as Hex, 0, 0) } as never)) |
| 378 | return reply({ error: { code: -32000, message: `unexpected ${d.functionName}` } }) |
| 379 | } |
| 380 | case 'eth_getLogs': { |
| 381 | const f = req.params[0] as { address: unknown; fromBlock: Hex; toBlock: Hex; topics?: unknown[] } |
| 382 | const from = Number(BigInt(f.fromBlock)) |
| 383 | const to = Number(BigInt(f.toBlock)) |
| 384 | if (to - from + 1 > 50) return reply({ error: { code: -32602, message: 'eth_getLogs is limited to 0 - 50 blocks range' } }) |
| 385 | const addrs = asList(f.address) |
| 386 | const t0 = f.topics?.[0] ? asList(f.topics[0]) : null |
| 387 | const t1 = f.topics?.[1] ? String(f.topics[1]).toLowerCase() : null |
| 388 | return result( |
| 389 | LOGS.filter((l) => { |
| 390 | const b = Number(BigInt(l.blockNumber)) |
| 391 | return b >= from && b <= to && addrs.includes(l.address.toLowerCase()) && (!t0 || t0.includes(l.topics[0].toLowerCase())) && (!t1 || l.topics[1]?.toLowerCase() === t1) |
| 392 | }), |
| 393 | ) |
| 394 | } |
| 395 | } |
| 396 | return reply({ error: { code: -32601, message: `method ${req.method} not in the fake` } }) |
| 397 | }) as typeof fetch |
| 398 | |
| 399 | const dom = installDom() |
| 400 | ok(!PENDING, 'fixture deployment is not a placeholder') |
| 401 | startChainWatch() |
| 402 | ok(await until(() => chainHealth.loadedRecent, 8000), 'page open: recent proposals loaded (getProposal + clearanceOf + per-proposal getLogs)') |
| 403 | const P = (id: string) => store.proposals.get(id)! |
| 404 | ok(P('1')?.chain?.status === 2 && P('2')?.chain?.status === 3 && P('3')?.chain?.status === 1, 'proposal #1 Vetoed, #2 Executed, #3 Pending on chain') |
| 405 | { |
| 406 | const a = P('1').guards.cre |
| 407 | ok(a.verdict === 'veto' && a.rule === 'R1_TOTAL_SUPPLY' && a.tx === T('v1') && a.block === 1002 && a.evidenceHash === EV1 && a.workflowId === WF, 'Vetoed by AirlockReceiver lands on the Chainlink CRE card with rule, tx, evidence hash and workflowId (Verdict)') |
| 408 | ok(P('1').reason?.hash === EV1 && P('1').card === 'evilMint', 'ReasonAttached carries the evidence hash; EvilMint is recognised by address') |
| 409 | const c = P('2').clearance |
| 410 | ok(c?.by === 'cre' && c.block === 1103 && c.tx === T('c2') && c.evidenceHash === EV2 && P('2').chain?.clearanceOf === EV2, 'Cleared by AirlockReceiver: clearance by the CRE (block, tx, evidence); clearanceOf read too') |
| 411 | ok(P('2').guards.cre.verdict === 'pass' && P('2').guards.cre.workflowId === WF, 'Verdict(action 1) marks the CRE card as passed with its workflowId') |
| 412 | ok(P('2').execute?.tx === T('e2') && P('2').execute?.block === 1180, 'Executed 80 blocks after the proposal is found (second getLogs range)') |
| 413 | ok(P('3').card === 'evilBaseFee' && !P('3').clearance && !P('3').chain?.clearanceOf, '#3 EvilBaseFee: pending, no clearance') |
| 414 | const calls = rpcCalls.filter((x) => x.method === 'eth_getLogs').map((x) => x.params[0] as { address: unknown; fromBlock: Hex; toBlock: Hex; topics: unknown[] }) |
| 415 | const maxRange = Math.max(...calls.map((f) => Number(BigInt(f.toBlock)) - Number(BigInt(f.fromBlock)) + 1)) |
| 416 | ok(calls.length > 0 && maxRange <= 50, `eth_getLogs ranges <= 50 blocks (max ${maxRange}, ${calls.length} calls)`) |
| 417 | ok(calls.every((f) => asList(f.address).includes(ADMIN.toLowerCase()) && asList(f.address).includes(RECV.toLowerCase())), 'every getLogs covers AirlockAdmin and AirlockReceiver') |
| 418 | ok(asList(calls[0].topics[0]).length === TOPICS.length && TOPICS.length === 6, 'topics: Proposed, Vetoed, Cleared, Executed, ReasonAttached, Verdict') |
| 419 | // history (eth_getLogs with a proposal id topic) goes to the history endpoint first (one public endpoint prunes old |
| 420 | // logs); everything else to the head endpoint; rotation only on failure |
| 421 | const isHist = (x: { method: string; params: unknown[] }) => x.method === 'eth_getLogs' && ((x.params[0] as { topics: unknown[] }).topics?.length ?? 0) > 1 |
| 422 | ok( |
| 423 | CHAIN.historyHttp[0] === 'https://sepolia.drpc.org' && |
| 424 | rpcCalls.filter(isHist).length > 0 && |
| 425 | rpcCalls.filter(isHist).every((x) => x.url === CHAIN.historyHttp[0]) && |
| 426 | rpcCalls.filter((x) => !isHist(x)).every((x) => x.url === CHAIN.http[0] || (x.method === 'eth_chainId' && x.url === CHAIN.historyHttp[0])), |
| 427 | `history getLogs go to ${CHAIN.historyHttp[0]}, every other JSON-RPC to ${CHAIN.http[0]} (rotation only on failure)`, |
| 428 | ) |
| 429 | ok(NoSocket.opened.includes(CHAIN.ws[0]), 'logs subscription over WS (eth_subscribe logs)') |
| 430 | } |
| 431 | ok(await until(() => !!store.receipts.get(T('v1').toLowerCase()) && !!store.receipts.get(T('c2').toLowerCase()) && blockTs.has(1000) && blockTs.has(1103), 5000), 'veto and clearance receipts (gasUsed, effectiveGasPrice) and block timestamps are read') |
| 432 | |
| 433 | // ---------- 6b. first visit: a neutral intro until its replay starts, never the newest proposal's final verdict ---------- |
| 434 | { |
| 435 | const plain = (h: string) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ') |
| 436 | setLang('zh-CN', false) |
| 437 | render() |
| 438 | const hero = plain(dom.els.get('h-head')?.innerHTML ?? '') + plain(dom.els.get('h-count')?.innerHTML ?? '') |
| 439 | const vm = plain(dom.els.get('v-main')?.innerHTML ?? '') |
| 440 | ok(labIntro() && hero.includes('正在载入一笔真实攻击') && hero.includes('重播即将开始') && vm.includes('等待重播'), `first visit, data in, replay not started yet: the intro (${hero.slice(0, 60)})`) |
| 441 | ok(!(dom.els.get('h-lock')?.innerHTML ?? '').includes('ph-ejected') && !(dom.els.get('h-lock')?.innerHTML ?? '').includes('ph-executed') && !/否决|放行|已弹出|已执行/.test(hero + vm), 'the intro shows no verdict: no ejected or executed airlock, no Veto or Clear') |
| 442 | // sections 7-9 read the page after its first-visit replay (the intro is over); 10g plays the autoplay itself |
| 443 | lab.autoplay = 'done' |
| 444 | render() |
| 445 | ok(!labIntro() && !plain(dom.els.get('h-head')?.innerHTML ?? '').includes('正在载入一笔真实攻击'), 'once the first-visit replay is over (or ruled out), the newest proposal is on screen') |
| 446 | } |
| 447 | |
| 448 | // ---------- 7. rendering in three languages ---------- |
| 449 | blocks.ingest(1200) |
| 450 | // the stand-in WebSocket never connects; the header feed is drawn as up (the reads above answered) |
| 451 | blocks.link = 'ws' |
| 452 | startTitle() |
| 453 | const html = () => dom.html() |
| 454 | const plain = (h: string) => h.replace(/<button data-lang[^>]*>[\s\S]*?<\/button>/g, '').replace(/<[^>]+>/g, ' ').replace(/'/g, "'").replace(/&/g, '&').replace(/\s+/g, ' ') |
| 455 | const lack = (words: string[], text: string) => { |
| 456 | const m = words.filter((w) => !text.includes(w)) |
| 457 | return m.length ? ` (missing: ${m.join(' | ')})` : '' |
| 458 | } |
| 459 | const region = (id: string) => dom.els.get(id)?.innerHTML ?? '' |
| 460 | function shot(id: string, l: Lang, details = false): { h: string; title: string } { |
| 461 | setLang(l, false) |
| 462 | store.activeId = id |
| 463 | ui.details = details |
| 464 | render() |
| 465 | const out = { h: html(), title: dom.doc.title } |
| 466 | ui.details = false |
| 467 | render() |
| 468 | return out |
| 469 | } |
| 470 | { |
| 471 | setLang('zh-CN', false) |
| 472 | store.activeId = '1' |
| 473 | render() |
| 474 | ok(region('details') === '' && !dom.els.get('details')!.classList.contains('open') && !plain(html()).includes('事件串流'), 'the details drawer is hidden by default (no event stream, gas or connection on the main screen)') |
| 475 | ok(!dom.els.get('help')!.classList.contains('open'), 'the help overlay is hidden by default') |
| 476 | const a = shot('1', 'zh-CN', true) |
| 477 | const w = ['升级气闸 · 每次代理升级都要过 Chainlink CRE', '外门', '气闸舱', '内门', '已弹出', '已弹出 · Chainlink CRE 否决', 'Chainlink CRE 判定', '否决', 'R1', '陌生地址的一次调用就改变了总供应量。', '总供应量被改', '提案后 2 块 · 24 秒', '永久封死 · 执行必定回滚', 'EvilMint', '否决上链', '毫发无伤', '证据哈希', '3,750,000 wei', '15 wei', 'workflow id', 'AirlockReceiver', '事件串流'] |
| 478 | ok(lack(w, plain(a.h)) === '', `zh-CN ejected: airlock, stamp, rule in plain words, blocks and seconds, pipeline, details drawer${lack(w, plain(a.h))}`) |
| 479 | ok(a.h.includes(`https://sepolia.etherscan.io/tx/${T('v1')}`) && a.h.includes(`https://sepolia.etherscan.io/address/${RECV}`), 'report tx and AirlockReceiver link to sepolia.etherscan.io') |
| 480 | ok(a.h.includes('class="lk ph-ejected') && a.h.includes('class="banner ph-ejected') && a.h.includes('pipe st-done k-veto') && a.h.includes('class="cn-stamp"'), 'airlock ejected: stamp, red banner, CRE pipeline done as a veto') |
| 481 | ok(a.h.includes(`title="${EV1}"`) && a.h.includes(EV1), 'evidence hash: short on screen with the full hash in its title, in full in the drawer') |
| 482 | ok(a.h.includes(WF), 'a real workflow id is listed in the drawer') |
| 483 | ok(a.title === 'Upgrade Airlock 升级气闸 · 战情室', `tab title: ${a.title}`) |
| 484 | const strip = region('strip') |
| 485 | const acts = [...strip.matchAll(/data-act="([^"]+)"/g)].map((m) => m[1].split(':')[0]) |
| 486 | ok(acts.every((x) => ['sel', 'follow', 'tab'].includes(x)) && strip.includes('data-act="sel:1"') && strip.includes('data-act="sel:3"') && strip.includes('class="pc ph-ejected on"') && strip.includes('data-act="tab:lab"'), `strip: one card per proposal, the shown one highlighted, the auto-follow switch and the tab back to the attack picker (${[...new Set(acts)].join(', ')})`) |
| 487 | ok(plain(strip).includes('R1 · 总供应量被改') && plain(strip).includes('否决 +2 块') && plain(strip).includes('放行 +3 块') && plain(strip).includes('已执行'), 'strip cards: rule, verdict blocks after the proposal, executed') |
| 488 | const ids = [...strip.matchAll(/data-act="sel:(\d+)"/g)].map((m) => m[1]) |
| 489 | ok(ids.join(',') === '1,2,3', `strip reads left to right, oldest to newest (${ids.join(',')})`) |
| 490 | const bar = region('t-ctl') |
| 491 | ok(bar.includes('data-act="details"') && bar.includes('data-act="help"') && (bar.match(/data-lang="/g) ?? []).length === 3, 'top bar: language switch, Details, ?') |
| 492 | ok(plain(region('t-status')).includes('实时 · Ethereum Sepolia') && plain(region('t-status')).includes('#1,200') && plain(region('t-cre')).includes('Chainlink CRE'), 'top bar: live on Ethereum Sepolia, the block counter, the Chainlink CRE mark') |
| 493 | ok(!/0x1{64}/i.test(html()), 'no placeholder workflow id on screen') |
| 494 | } |
| 495 | { |
| 496 | const a = shot('2', 'zh-CN', true) |
| 497 | const w = ['已执行 · DemoBTC v2 上线', '升级已生效', '区块 #1,180 开启', 'DemoBTC v2', '只多一个 version() 的诚实升级:放行后执行', '放行', '撑过攻击模拟:每条规则都守住了', '提案后 3 块 · 36 秒', '放行上链', '已换上 DemoBTC v2', '执行交易'] |
| 498 | ok(lack(w, plain(a.h)) === '' && a.h.includes('class="lk ph-executed') && a.h.includes('pipe st-done k-clear'), `zh-CN executed: cleared by the CRE, then the inner door opened${lack(w, plain(a.h))}`) |
| 499 | ok(a.h.includes(`https://sepolia.etherscan.io/tx/${T('e2')}`) && a.h.includes(`https://sepolia.etherscan.io/tx/${T('c2')}`), 'executed: report tx and execute tx links') |
| 500 | } |
| 501 | { |
| 502 | const a = shot('3', 'zh-CN') |
| 503 | const w = ['冷静期已满', '链上还没有放行', '冷静期已满 · 没有放行,内门继续锁住', '锁住 · 没有放行', '判定中', 'EvilBaseFee', '模拟里装成好人,上了真实区块才让人增发'] |
| 504 | ok(lack(w, plain(a.h)) === '' && a.h.includes('class="lk ph-waiting') && a.h.includes('class="banner ph-waiting') && a.h.includes('pipe st-run'), `zh-CN past readyBlock without clearance: inner door stays locked${lack(w, plain(a.h))}`) |
| 505 | } |
| 506 | for (const [id, words] of [ |
| 507 | ['1', ['升級氣閘 · 每次代理升級都要過 Chainlink CRE', '外門', '氣閘艙', '內門', '已彈出', '陌生地址的一次呼叫就改變了總供應量。', '提案後 2 塊 · 24 秒', '永久封死', '證據雜湊', '事件串流']], |
| 508 | ['2', ['已執行 · DemoBTC v2 上線', '升級已生效', '放行', '撐過攻擊模擬:每條規則都守住了', '放行上鏈']], |
| 509 | ['3', ['冷靜期已滿', '鎖住 · 沒有放行', '模擬裡裝成好人,上了真實區塊才讓人增發']], |
| 510 | ] as const) { |
| 511 | const a = shot(id, 'zh-TW', id === '1') |
| 512 | const p = plain(a.h) |
| 513 | ok(lack([...words], p) === '', `zh-TW #${id}${lack([...words], p)}`) |
| 514 | const bad = [...new Set(p.match(new RegExp(SIMPLIFIED_ONLY.source, 'g')) ?? [])] |
| 515 | ok(bad.length === 0, `zh-TW #${id} has no simplified characters${bad.length ? ': ' + bad.join('') : ''}`) |
| 516 | if (id === '1') ok(a.title === 'Upgrade Airlock 升級氣閘 · 戰情室', `zh-TW tab title: ${a.title}`) |
| 517 | } |
| 518 | for (const [id, words] of [ |
| 519 | ['1', ['Every proxy upgrade, cleared by Chainlink CRE', 'Live · Ethereum Sepolia', 'Outer door', 'Chamber', 'Inner door', 'Ejected', 'Ejected · vetoed by Chainlink CRE', "A stranger's call changed the total supply.", 'R1 · Supply changed', '2 blocks · 24 s after the proposal', 'Sealed for good · execute reverts', 'Chainlink CRE verdict', 'Attack simulation', 'eth_simulateV1 on 4 RPC providers', 'All answers agree', 'at least 2 of 4 answer · every answer matches', 'Consensus', 'Signed report', 'Veto on chain', 'Proposal tx', 'Report tx', 'Evidence hash', 'Event stream', 'Verdict tx cost']], |
| 520 | ['2', ['Executed · DemoBTC v2 is live', 'Upgrade is live', 'Survived the attack simulation: every rule held.', 'Clear on chain', '3 blocks · 36 s after the proposal', 'now runs DemoBTC v2', 'Execute tx', 'Every rule held', 'Clear +3 blocks · executed']], |
| 521 | ['3', ['Cooldown over', 'Locked · no clearance', 'Judging', 'Plays nice in a simulation']], |
| 522 | ] as const) { |
| 523 | const a = shot(id, 'en', id === '1') |
| 524 | const p = plain(a.h) |
| 525 | ok(lack([...words], p) === '', `en #${id}${lack([...words], p)}`) |
| 526 | const bad = [...new Set(p.match(new RegExp(CJK.source, 'g')) ?? [])] |
| 527 | ok(bad.length === 0, `en #${id} has no Chinese characters${bad.length ? ': ' + bad.join('') : ''}`) |
| 528 | if (id === '1') ok(a.title === 'Upgrade Airlock · War Room', `en tab title: ${a.title}`) |
| 529 | } |
| 530 | { |
| 531 | const keys = Object.keys(DICT['zh-CN']) |
| 532 | ok((['zh-TW', 'en'] as Lang[]).every((l) => keys.every((k) => typeof DICT[l][k] === 'string' && DICT[l][k].length > 0)), `all ${keys.length} strings exist in all three languages`) |
| 533 | const tw = keys.filter((k) => SIMPLIFIED_ONLY.test(DICT['zh-TW'][k])) |
| 534 | ok(tw.length === 0, `zh-TW strings have no simplified characters${tw.length ? ': ' + tw.slice(0, 5).join(', ') : ''}`) |
| 535 | ok(keys.every((k) => !CJK.test(DICT.en[k])), 'en strings have no Chinese characters') |
| 536 | } |
| 537 | |
| 538 | // ---------- 7b. presenter controls, help overlay, moments ---------- |
| 539 | { |
| 540 | const want: Record<string, Action> = { ArrowLeft: 'prev', PageUp: 'prev', ArrowRight: 'next', PageDown: 'next', n: 'newest', N: 'newest', Home: 'newest', ' ': 'replay', Enter: 'replay', f: 'follow', p: 'loop', d: 'details', l: 'lang', '?': 'help', h: 'help', Escape: 'close' } |
| 541 | ok(Object.entries(want).every(([k, a]) => keyAction(k) === a) && keyAction('x') === null && keyAction('d', { meta: true }) === null && keyAction('r', { ctrl: true }) === null, 'keys: arrows and Page Up/Down step, N newest, Space replay, F follow, P loop, D details, L language, ? help, Esc close; Ctrl/Alt/Cmd combos left alone') |
| 542 | const acts = new Set(HELP_ROWS.map((r) => r.action)) |
| 543 | ok((['prev', 'newest', 'replay', 'follow', 'loop', 'details', 'lang', 'help', 'close'] as Action[]).every((a) => acts.has(a)), 'the help overlay lists every action') |
| 544 | ok(stepId(['3', '1', '2'], '2', -1) === '1' && stepId(['3', '1', '2'], '2', 1) === '3' && stepId(['1', '2'], '2', 1) === '2' && stepId(['1', '2'], '1', -1) === '1' && stepId(['1', '2'], null, 1) === '2' && stepId([], '1', 1) === null, '← → step through proposals by id and stop at either end') |
| 545 | ok(parsePick('4') === '4' && parsePick(' 12 ') === '12' && [null, '', '0', '-1', 'x', '1e3', '4;'].every((x) => parsePick(x) === null), '?p= accepts a proposal id only') |
| 546 | ok(realWorkflowId(WF) === WF && realWorkflowId(('0x' + '11'.repeat(32)) as Hex) === null && realWorkflowId(ZERO32) === null && realWorkflowId('0x12') === null && realWorkflowId(undefined) === null, 'workflow id: the simulator placeholder 0x1111…1111 and zero are hidden') |
| 547 | const css = readFileSync(join(WEB, 'src', 'style.css'), 'utf8') |
| 548 | ok(STAGE_QUERY === '(min-width: 960px) and (min-aspect-ratio: 5/4)' && css.split(`@media not all and ${STAGE_QUERY}`).length === 3 && css.includes(`@media (hover: hover) and ${STAGE_QUERY}`) && !css.includes('13/10'), 'stage and stack layouts use the same media query in ui.ts and style.css; a 4:3 or 5:4 projector gets the stage') |
| 549 | ok(/\.clean \.ctl,\s*\.clean \.s-hint \{\s*display: none/.test(css) && /@media \(hover: none\) \{\s*\.s-hint \{\s*display: none/.test(css) && /\.idle \.ctl,[\s\S]*?opacity: 0/.test(css), 'presenter chrome: hidden with ?clean=1, no key hints on touch screens, faded out when the mouse is idle') |
| 550 | ok(parseClean('1') && parseClean('') && parseClean('true') && !parseClean(null) && !parseClean('0') && !parseClean('no') && !CLEAN, '?clean=1 (or a bare ?clean) is recording mode; off by default') |
| 551 | ok(/\.drawer \{[^}]*background: #0a0f1e;/.test(css), 'the details drawer is opaque (nothing ghosts through it)') |
| 552 | ok(/\.cn-stamp \{[^}]*font-size: clamp\(16px, 12\.5cqw, 28px\)/.test(css) && /\.count \{[^}]*container-type: inline-size/.test(css), 'phone: the stamps size themselves to the chamber width') |
| 553 | ok(/\.dl-v \.ph \{\s*display: block;\s*\}\s*\.dl-v \.sep \{\s*display: none;/.test(css), 'phone: door captions are rows, one phrase per line, no stray separators') |
| 554 | ok(/prefers-reduced-motion: reduce[\s\S]*animation: none !important/.test(css), 'prefers-reduced-motion turns every animation off') |
| 555 | ok(!/@import|url\(\s*['"]?https?:/i.test(css) && !readFileSync(join(WEB, 'index.html'), 'utf8').includes('https://'), 'no web fonts or other CDN requests (system font stacks only)') |
| 556 | ok(momentsFor('chamber', 'ejected').join() === 'veto' && momentsFor(null, 'chamber').join() === 'arrive' && momentsFor('idle', 'ejected').join() === 'arrive,veto' && momentsFor('chamber', 'cleared').join() === 'clear' && momentsFor('ready', 'executed').join() === 'exec' && momentsFor('chamber', 'executed').join() === 'clear,exec' && momentsFor('cleared', 'ready').join() === '' && momentsFor('ejected', 'idle').join() === '', 'moments: arrival, veto, clearance, inner door; none when nothing new happened') |
| 557 | for (const l of ['zh-CN', 'zh-TW', 'en'] as Lang[]) { |
| 558 | setLang(l, false) |
| 559 | ui.help = true |
| 560 | render() |
| 561 | const hp = plain(region('help')) |
| 562 | const words = l === 'en' ? ['Presenter shortcuts', 'Previous (older) proposal', 'Jump to the newest proposal', "Replay the selected proposal's verdict moment", 'Auto-follow', 'Developer details drawer', 'Switch language', '?mode=offline', 'Space'] : l === 'zh-TW' ? ['講者快捷鍵', '跳到最新提案', '重播選取提案的判定瞬間', '自動跟隨', '空白鍵'] : ['讲者快捷键', '跳到最新提案', '重播选中提案的判定瞬间', '自动跟随', '空格'] |
| 563 | ok(dom.els.get('help')!.classList.contains('open') && lack(words, hp) === '', `${l} help overlay${lack(words, hp)}`) |
| 564 | ui.help = false |
| 565 | } |
| 566 | render() |
| 567 | // a selection by the viewer fades in; it plays no moment |
| 568 | setLang('en', false) |
| 569 | store.activeId = '2' |
| 570 | render() |
| 571 | store.activeId = '1' |
| 572 | render() |
| 573 | ok(!/ m-(arrive|veto|clear|exec)/.test(region('h-lock')) && region('h-head').includes(' enter'), 'picking another proposal fades it in without replaying its verdict') |
| 574 | } |
| 575 | |
| 576 | // ---------- 8. live: outer door → chamber → CRE clearance → cooldown over → inner door ---------- |
| 577 | setLang('zh-CN', false) |
| 578 | { |
| 579 | HEAD = 1210 |
| 580 | NEXT = 5 |
| 581 | PROPS.set(4, prop(IMPL.v2, 1, 1205)) |
| 582 | const p4 = mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 4n, kind: 0, target: IMPL.v2, value: 0n, data: '0x', codehash: H32('c4'), readyBlock: 1230n }, 1205, T('p4'), 0) |
| 583 | LOGS.push(p4) |
| 584 | handleLog(p4, true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive: (ts) => replay.onLive(ts) }) |
| 585 | blocks.ingest(1210) |
| 586 | ok(store.activeId === '4' && store.arrival?.id === '4', 'auto-follow: a live Proposed takes over the screen') |
| 587 | render() |
| 588 | let h = plain(html()) |
| 589 | const ch = ['提案于区块 #1,205', '20 块', '冷静期剩余', '≈ 4:00', '等待 Chainlink CRE 判定', '已进气闸 · 等待 Chainlink CRE 判定', '锁住 · 等 Chainlink CRE 放行', '判定中', '毫发无伤'] |
| 590 | ok(lack(ch, h) === '' && html().includes('class="lk ph-chamber'), `chamber: outer door shut behind it, the cooldown in blocks and m:ss, inner door locked${lack(ch, h)}`) |
| 591 | ok(region('h-lock').includes(' m-arrive'), 'the live arrival plays its moment (outer door, capsule)') |
| 592 | // a Cleared from any other contract is ignored |
| 593 | handleLog(mkLog(ADMIN_ABI, '0x000000000000000000000000000000000000dEaD', 'Cleared', { id: 4n, clearer: RECV, evidenceHash: EV2 }, 1206, T('spoof'), 0), true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} }) |
| 594 | ok(!P('4').clearance, 'a Cleared log from another address does not count') |
| 595 | const EV4 = H32('evidence #4') |
| 596 | const clr = [ |
| 597 | mkLog(ADMIN_ABI, ADMIN, 'Cleared', { id: 4n, clearer: RECV, evidenceHash: EV4 }, 1207, T('c4'), 0), |
| 598 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 4n, action: 1, ruleHash: ZERO32, evidenceHash: EV4, workflowId: ('0x' + '11'.repeat(32)) as Hex }, 1207, T('c4'), 1), |
| 599 | ] |
| 600 | LOGS.push(...clr) |
| 601 | CLEAR.set(4, EV4) |
| 602 | for (const l of clr) handleLog(l, true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} }) |
| 603 | // a second clearance keeps the first evidence hash |
| 604 | handleLog(mkLog(ADMIN_ABI, ADMIN, 'Cleared', { id: 4n, clearer: DATA.approver, evidenceHash: H32('later') }, 1208, T('c4b'), 0), true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} }) |
| 605 | ok(P('4').clearance?.evidenceHash === EV4 && P('4').clearance?.by === 'cre', 'a later Cleared keeps the first evidence hash (as AirlockAdmin does)') |
| 606 | blockTs.set(1205, blockTime(1205)) |
| 607 | blockTs.set(1207, blockTime(1207)) |
| 608 | blocks.ingest(1211) |
| 609 | render() |
| 610 | h = plain(html()) |
| 611 | const cl = ['Chainlink CRE 已放行', 'Chainlink CRE 已放行 · 冷静期满内门就开', '已解锁 · 区块 #1,230 起可开', '放行', '提案后 2 块 · 24 秒', '放行上链'] |
| 612 | ok(lack(cl, h) === '' && html().includes('class="lk ph-cleared') && html().includes(`title="${EV4}"`), `cleared: "cleared by Chainlink CRE" with blocks, seconds and the evidence hash; the inner door unlocks${lack(cl, h)}`) |
| 613 | ok(region('h-lock').includes(' m-clear') && region('v-pipe').includes(' m-clear') && !region('h-lock').includes(' m-arrive'), 'the clearance plays its moment: the pipeline lights up, the lock opens') |
| 614 | ok(region('h-stamp').includes('cn-stamp good') && plain(region('h-stamp')).includes('已放行') && dom.els.get('h-wrap')!.classList.contains('stamping'), 'the clearance lands with a green stamp over the chamber') |
| 615 | ok(!region('h-count').includes(' m-clear'), 'the ticking countdown never carries the moment (a redraw would restart it)') |
| 616 | ui.details = true |
| 617 | render() |
| 618 | ok(!plain(region('details')).includes('Workflow id') && !plain(region('details')).includes('workflow id') && !region('details').includes('1'.repeat(64)), 'the simulator placeholder workflow id is not shown, not even in the drawer') |
| 619 | ui.details = false |
| 620 | blocks.ingest(1230) |
| 621 | render() |
| 622 | h = plain(html()) |
| 623 | ok(lack(['Chainlink CRE 已放行 · 可以执行', '可以执行', '已解锁 · 冷静期已满'], h) === '' && html().includes('class="lk ph-ready'), 'cooldown over and cleared: ready to execute') |
| 624 | const ex = mkLog(ADMIN_ABI, ADMIN, 'Executed', { id: 4n, executor: OWNER }, 1231, T('e4'), 0) |
| 625 | handleLog(ex, true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} }) |
| 626 | blocks.ingest(1231) |
| 627 | render() |
| 628 | ok(plain(html()).includes('已执行 · DemoBTC v2 上线') && html().includes('class="lk ph-executed') && region('h-lock').includes(' m-exec'), 'Executed: the inner door opens (moment) and the proxy runs v2') |
| 629 | ok(store.stream.some((l) => l.type === 'Cleared' && l.text.includes('Cleared #4') && l.text.includes('Chainlink CRE')) && store.stream.some((l) => l.type === 'Verdict' && l.text.includes('Verdict #4') && l.text.includes('放行')), 'event stream (drawer): Cleared #4 by Chainlink CRE and its Verdict') |
| 630 | } |
| 631 | |
| 632 | // ---------- 8a. auto-follow off: a new proposal waits in the strip ---------- |
| 633 | { |
| 634 | store.follow = false |
| 635 | store.activeId = '2' |
| 636 | setManualPick(true) |
| 637 | const p9 = mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 9n, kind: 0, target: IMPL.evilOwner, value: 0n, data: '0x', codehash: H32('c9'), readyBlock: 1257n }, 1232, T('p9'), 0) |
| 638 | handleLog(p9, true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} }) |
| 639 | ok(store.activeId === '2' && store.proposals.has('9'), 'auto-follow off: the screen stays on the picked proposal') |
| 640 | render() |
| 641 | ok(region('strip').includes('data-act="sel:9"') && plain(region('strip')).includes('自动跟随 · 关'), 'the new proposal shows up as a card; the switch says auto-follow is off') |
| 642 | store.proposals.delete('9') |
| 643 | store.follow = true |
| 644 | setManualPick(false) |
| 645 | pickActive() |
| 646 | } |
| 647 | |
| 648 | // ---------- 8b. an approver clearance, then the CRE sweeper's R3B veto of EvilDrain wins ---------- |
| 649 | { |
| 650 | const NOHOOK = { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} } |
| 651 | const EVA = H32('approver evidence #5') |
| 652 | const EV5 = H32('evidence #5') |
| 653 | HEAD = 1245 |
| 654 | NEXT = 6 |
| 655 | PROPS.set(5, prop(IMPL.evilDrain, 1, 1240)) |
| 656 | const p5 = mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 5n, kind: 0, target: IMPL.evilDrain, value: 0n, data: '0x', codehash: H32('c5'), readyBlock: 1265n }, 1240, T('p5'), 0) |
| 657 | const a5 = mkLog(ADMIN_ABI, ADMIN, 'Cleared', { id: 5n, clearer: DATA.approver, evidenceHash: EVA }, 1241, T('a5'), 0) |
| 658 | LOGS.push(p5, a5) |
| 659 | CLEAR.set(5, EVA) |
| 660 | handleLog(p5, true, { ...NOHOOK, onLive: (ts) => replay.onLive(ts) }) |
| 661 | handleLog(a5, true, NOHOOK) |
| 662 | blocks.ingest(1242) |
| 663 | ok(P('5').card === 'evilDrain' && P('5').clearance?.by === 'approver', 'EvilDrain is recognised by address; the approver cleared it by hand') |
| 664 | let a = shot('5', 'zh-CN') |
| 665 | const pre = ['EvilDrain', '新增 rescue()', 'approver(人工) 已放行 · 冷静期满内门就开', 'approver 人工放行;Chainlink CRE 仍会补判,否决优先'] |
| 666 | ok(lack(pre, plain(a.h)) === '' && a.h.includes('class="lk ph-cleared'), `approver-cleared EvilDrain: the verdict card says the CRE still judges it${lack(pre, plain(a.h))}`) |
| 667 | const ve = [ |
| 668 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 5n, guardian: RECV, ruleHash: ruleHashOf('R3B_THIRD_PARTY_FUNDS'), vetoCount: 1n }, 1243, T('v5'), 0), |
| 669 | mkLog(ADMIN_ABI, ADMIN, 'ReasonAttached', { id: 5n, guardian: RECV, reportHash: EV5 }, 1243, T('v5'), 1), |
| 670 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 5n, action: 0, ruleHash: ruleHashOf('R3B_THIRD_PARTY_FUNDS'), evidenceHash: EV5, workflowId: WF }, 1243, T('v5'), 2), |
| 671 | ] |
| 672 | LOGS.push(...ve) |
| 673 | PROPS.set(5, prop(IMPL.evilDrain, 2, 1240, 1n)) |
| 674 | for (const l of ve) handleLog(l, true, NOHOOK) |
| 675 | blocks.ingest(1245) |
| 676 | a = shot('5', 'zh-CN') |
| 677 | const zh = ['已弹出 · Chainlink CRE 否决', 'R3B', '外人没有授权就搬走了持有人的代币。', 'EvilDrain'] |
| 678 | ok(lack(zh, plain(a.h)) === '' && a.h.includes('class="lk ph-ejected') && a.h.includes('pipe st-done k-veto'), `the CRE veto (R3B) wins over the approver's clearance${lack(zh, plain(a.h))}`) |
| 679 | const tw = plain(shot('5', 'zh-TW').h) |
| 680 | ok(lack(['外人沒有授權就搬走了持有人的代幣。', '新增 rescue():不經授權就能搬走別人的幣'], tw) === '' && !SIMPLIFIED_ONLY.test(tw), 'zh-TW: R3B and the EvilDrain card, no simplified characters') |
| 681 | const en = plain(shot('5', 'en').h) |
| 682 | const enw = ['EvilDrain', "Adds rescue(): anyone can move other people's tokens", "An outsider moved a holder's tokens without an allowance.", 'R3B · Holder funds moved'] |
| 683 | ok(lack(enw, en) === '' && !CJK.test(en), `en: R3B and the EvilDrain card${lack(enw, en)}`) |
| 684 | } |
| 685 | |
| 686 | // ---------- 8c. a veto whose log is out of reach: nobody is named ---------- |
| 687 | { |
| 688 | PROPS.set(6, prop(IMPL.evilDrain, 2, 1250, 1n)) |
| 689 | const p6 = store.ensure('6') |
| 690 | Object.assign(p6, { kind: '0', target: IMPL.evilDrain, codehash: H32('c6'), proposeBlock: 1250, readyBlock: 1275, card: 'evilDrain' }) |
| 691 | p6.chain = { status: 2, vetoCount: 1, proposer: OWNER, clearanceOf: null } |
| 692 | const en = plain(shot('6', 'en').h) |
| 693 | ok(en.includes('Ejected · vetoed on chain') && en.includes('Vetoed on chain (the veto log is out of reach)') && !en.includes('vetoed by Chainlink CRE') && !en.includes('emergency guardian'), 'a veto out of reach: ejected, and no one is named as the vetoer') |
| 694 | } |
| 695 | |
| 696 | // ---------- 8d. "remove the CRE receiver" (a guardian change) vetoed with R7, simulator workflow id ---------- |
| 697 | { |
| 698 | const NOHOOK = { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} } |
| 699 | const EV7 = H32('evidence #7') |
| 700 | PROPS.set(7, { ...prop(RECV, 2, 1260, 1n), kind: 1, value: 0n, codehash: ZERO32 }) |
| 701 | const logs7 = [ |
| 702 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 7n, kind: 1, target: RECV, value: 0n, data: '0x', codehash: ZERO32, readyBlock: 1285n }, 1260, T('p7'), 0), |
| 703 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 7n, guardian: RECV, ruleHash: ruleHashOf('R7_CONFIG_GUARD'), vetoCount: 1n }, 1261, T('v7'), 0), |
| 704 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 7n, action: 0, ruleHash: ruleHashOf('R7_CONFIG_GUARD'), evidenceHash: EV7, workflowId: ('0x' + '11'.repeat(32)) as Hex }, 1261, T('v7'), 1), |
| 705 | ] |
| 706 | for (const l of logs7) handleLog(l, true, NOHOOK) |
| 707 | blocks.ingest(1262) |
| 708 | ok(P('7').kind === '1' && P('7').value === '0', 'Proposed.value is kept (guardian change: 0 = remove)') |
| 709 | setLang('en', false) |
| 710 | ok(identOf(P('7')).title === 'Remove the Chainlink CRE guardian' && identOf(P('7')).name === 'Remove CRE', 'a guardian change that removes the CRE receiver is named as such') |
| 711 | const a = shot('7', 'en', true) |
| 712 | const w = ['Proposal #7 · Guardian change', 'Remove the Chainlink CRE guardian', 'R7', "A guardian, cooldown or break-glass change without the approver's pre-approval.", 'R7 · Config not approved', 'Ejected · vetoed by Chainlink CRE'] |
| 713 | ok(lack(w, plain(a.h)) === '' && !a.h.includes('1'.repeat(64)) && !plain(a.h).includes('Workflow id'), `R7 veto of "remove the CRE receiver"; the placeholder workflow id stays hidden${lack(w, plain(a.h))}`) |
| 714 | // R7 is decided from chain reads: the verdict card must not claim an attack simulation that never ran |
| 715 | const pipe = plain(region('v-pipe')) |
| 716 | const cfgSteps = ['Config check', 'chain reads at the proposal block: approver pre-approval', 'Consensus', 'Signed report', 'Veto on chain'] |
| 717 | ok(lack(cfgSteps, pipe) === '' && !pipe.includes('Attack simulation') && !pipe.includes('All answers agree') && !pipe.includes('eth_simulateV1') && region('v-pipe').includes('n-4'), `R7 pipeline: config check, consensus, signed report, on chain; no simulation steps${lack(cfgSteps, pipe)}`) |
| 718 | const zh = plain(shot('7', 'zh-CN').h) |
| 719 | ok(lack(['移除 Chainlink CRE guardian', '移除 CRE guardian', 'R7 · 配置变更未核准', '配置检查', '在提案区块读链上状态:approver 是否预先核准', '共识聚合'], zh) === '' && !plain(region('v-pipe')).includes('攻击模拟') && !plain(region('v-pipe')).includes('答案全部一致'), 'zh-CN: the same proposal in Chinese, with the config-check pipeline') |
| 720 | const tw = plain(shot('7', 'zh-TW').h) |
| 721 | ok(tw.includes('設定檢查') && !plain(region('v-pipe')).includes('攻擊模擬') && !SIMPLIFIED_ONLY.test(tw), 'zh-TW: config-check pipeline, no simplified characters') |
| 722 | setLang('zh-CN', false) |
| 723 | } |
| 724 | |
| 725 | // ---------- 8e. the pipeline is what the workflow really ran; a cleared config change says so ---------- |
| 726 | { |
| 727 | const names = (kind: string, rule: string | null) => pipelineOf(kind, rule).map((s) => s.k).join(',') |
| 728 | ok(names('0', null) === 'p.sim,p.agree,p.don,p.sign,p.chain' && names('0', 'R1_TOTAL_SUPPLY') === 'p.sim,p.agree,p.don,p.sign,p.chain' && names('0', 'R9_NEW_OPCODES') === 'p.sim,p.agree,p.don,p.sign,p.chain', 'an upgrade: attack simulation on 4 providers, all answers agree, DON, report, chain') |
| 729 | ok(['1', '2', '3'].every((k) => names(k, 'R7_CONFIG_GUARD') === 'p.cfg,p.don,p.sign,p.chain' && names(k, null) === 'p.cfg,p.don,p.sign,p.chain'), 'a change to the airlock (kinds 1-3): config check from chain reads, then DON, report, chain') |
| 730 | ok(names('0', 'R5_STRICT_CODEHASH') === 'p.static,p.don,p.sign,p.chain' && names('0', 'R8_INIT_DATA') === 'p.static,p.don,p.sign,p.chain', 'R5 and R8 are decided before any simulation') |
| 731 | ok(names('0', 'R6_SIM_FAILURE') === 'p.simFail,p.don,p.sign,p.chain' && pipelineOf('0', 'R6_SIM_FAILURE')[0].fail === true, 'R6: the simulation could not decide (fail-closed), drawn as the failing step') |
| 732 | // a shorter cooldown cleared by the CRE (pre-approved by the approver): no "survived the attack simulation" |
| 733 | const p8 = store.ensure('8') |
| 734 | Object.assign(p8, { kind: '2', value: '20', target: '0x0000000000000000000000000000000000000000', codehash: ZERO32, proposeBlock: 1262, readyBlock: 1287 }) |
| 735 | p8.chain = { status: 1, vetoCount: 0, proposer: OWNER, clearanceOf: H32('evidence #8') } |
| 736 | p8.clearance = { evidenceHash: H32('evidence #8'), clearer: RECV, by: 'cre', tx: T('c8'), block: 1263 } |
| 737 | p8.guards.cre = { verdict: 'pass', tx: T('c8'), block: 1263, evidenceHash: H32('evidence #8') } |
| 738 | const en = shot('8', 'en') |
| 739 | const p = plain(en.h) |
| 740 | ok(lack(['Change the cooldown to 20 blocks', 'The config check passed: the approver pre-approved it, or it does not weaken the airlock.', 'Config check', 'Config check passed', 'Clear on chain'], p) === '' && !p.includes('Survived the attack simulation') && !plain(region('v-pipe')).includes('Attack simulation'), `a cleared config change: config check passed, no attack simulation claimed${lack(['The config check passed'], p)}`) |
| 741 | const zh = plain(shot('8', 'zh-CN').h) |
| 742 | ok(zh.includes('配置检查通过:approver 已预先核准,或这个变更没有削弱气闸。') && !zh.includes('撑过攻击模拟'), 'zh-CN: a cleared config change') |
| 743 | store.proposals.delete('8') |
| 744 | pickActive() |
| 745 | } |
| 746 | |
| 747 | // ---------- 8f. small pieces of the stage ---------- |
| 748 | { |
| 749 | const h = phrases('Sealed for good · execute reverts') |
| 750 | ok((h.match(/class="ph"/g) ?? []).length === 2 && (h.match(/class="sep"/g) ?? []).length === 1, 'door captions: one span per phrase, the separator on its own (hidden on a phone)') |
| 751 | ok(phrases('冷静期 25 块 ≈ 5 分钟').includes('25\u00a0块') && phrases('冷静期 25 块 ≈ 5 分钟').includes('≈\u00a05\u00a0分钟') && phrases('Proposed in block #11,950,735').includes('block\u00a0#11,950,735'), 'door captions: numbers stay with their units, "block" with its number') |
| 752 | setLang('en', false) |
| 753 | ok(t('p.agreeS') === 'at least 2 of 4 answer · every answer matches', 'en: the agreement step reads correctly') |
| 754 | setLang('zh-CN', false) |
| 755 | } |
| 756 | |
| 757 | // ---------- 8g. a re-sent veto report: anyone may re-send a mined report until it expires; the first veto stays ---------- |
| 758 | { |
| 759 | const NOHOOK = { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive() {} } |
| 760 | const EVX = H32('evidence #40') |
| 761 | const WFX = H32('a workflow id the re-sender chose') |
| 762 | const R1 = ruleHashOf('R1_TOTAL_SUPPLY') |
| 763 | // one report transaction: the receiver's Verdict, then AirlockAdmin's Vetoed and ReasonAttached |
| 764 | const report = (b: number, tx: Hex, wf: Hex) => [ |
| 765 | mkLog(RECEIVER_ABI, RECV, 'Verdict', { proposalId: 40n, action: 0, ruleHash: R1, evidenceHash: EVX, workflowId: wf }, b, tx, 0), |
| 766 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 40n, guardian: RECV, ruleHash: R1, vetoCount: 1n }, b, tx, 1), |
| 767 | mkLog(ADMIN_ABI, ADMIN, 'ReasonAttached', { id: 40n, guardian: RECV, reportHash: EVX }, b, tx, 2), |
| 768 | ] |
| 769 | const proposed = mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 40n, kind: 0, target: IMPL.evilMint, value: 0n, data: '0x', codehash: H32('c40'), readyBlock: 1525n }, 1500, T('p40'), 0) |
| 770 | const first = report(1502, T('v40'), WF) |
| 771 | const again = report(1530, T('v40 re-sent'), WFX) |
| 772 | const check = (label: string) => { |
| 773 | const v = airlockView(P('40'), 1600) |
| 774 | const a = P('40').guards.cre |
| 775 | ok( |
| 776 | v.phase === 'ejected' && v.veto?.afterBlocks === 2 && v.veto.tx === T('v40') && v.veto.workflowId === WF && v.veto.toInner === 23 && a.block === 1502 && P('40').reason?.tx === T('v40') && a.evidenceHash === EVX, |
| 777 | `${label}: the first veto stays on screen (2 blocks after the proposal, its tx and workflow id), not the re-sent one (shown: ${v.veto?.afterBlocks} blocks, ${v.veto?.tx === T('v40') ? 'first tx' : 'other tx'}, ${v.veto?.workflowId === WF ? 'first workflow id' : 'other workflow id'})`, |
| 778 | ) |
| 779 | } |
| 780 | const streamBefore = store.stream.length |
| 781 | for (const l of [proposed, ...first, ...again]) handleLog(l, false, NOHOOK) |
| 782 | check('a veto report re-sent 28 blocks later') |
| 783 | ok(store.stream.slice(streamBefore).filter((l) => l.type === 'Vetoed' && l.text.includes('#40')).length === 2, 'the event stream still lists both Vetoed logs: both are real chain events') |
| 784 | store.proposals.delete('40') |
| 785 | resetSeen() |
| 786 | for (const l of [proposed, ...again, ...first]) handleLog(l, false, NOHOOK) |
| 787 | check('the same logs out of order (the re-sent report read first)') |
| 788 | store.proposals.delete('40') |
| 789 | pickActive() |
| 790 | } |
| 791 | |
| 792 | |
| 793 | // ---------- 10. attack picker (lab.ts) and operator mode (operator-client.ts) ---------- |
| 794 | { |
| 795 | // 10a. which card a proposal is |
| 796 | const names = store.implNames |
| 797 | const mk = (id: string, kind: string, target: string, value = '0', extra: Partial<Proposal> = {}): Proposal => ({ ...newProposal(id), kind, target, value, codehash: '0x02', proposeBlock: 100, readyBlock: 125, ...extra }) |
| 798 | ok(LAB_IDS.join(',') === 'evil-mint,evil-owner,evil-fee,evil-basefee,evil-drain,remove-receiver,v2' && LAB_CARDS.length === 7, 'picker: seven cards in the order of the operator API card ids') |
| 799 | const upg = (['evilMint', 'evilOwner', 'evilFee', 'evilBaseFee', 'evilDrain', 'v2'] as const).map((k) => labIdOf(mk('1', '0', IMPL[k]), names)) |
| 800 | ok(upg.join(',') === 'evil-mint,evil-owner,evil-fee,evil-basefee,evil-drain,v2', `card -> proposal: an upgrade to each demo implementation is its card (${upg.join(',')})`) |
| 801 | ok(labIdOf(mk('1', '1', RECV, '0'), names) === 'remove-receiver' && labIdOf(mk('1', '1', RECV.toUpperCase().replace('0X', '0x'), '0'), names) === 'remove-receiver', 'card -> proposal: removing the CRE receiver (guardian change, value 0) is "remove-receiver"') |
| 802 | ok( |
| 803 | [mk('1', '1', RECV, '1'), mk('1', '1', OWNER, '0'), mk('1', '0', IMPL.v1), mk('1', '0', IMPL.paused), mk('1', '2', ZERO32.slice(0, 42), '20'), mk('1', '3', IMPL.paused), mk('1', '0', '')].every((p) => labIdOf(p, names) === null), |
| 804 | 'card -> proposal: adding a guardian, another guardian, v1, the paused target, a cooldown or break-glass change are not picker cards', |
| 805 | ) |
| 806 | const ME = (id: string, b: number, extra: Partial<Proposal> = {}) => mk(id, '0', IMPL.evilMint, '0', { proposeBlock: b, readyBlock: b + 25, proposeTx: H(b), ...extra }) |
| 807 | const veto = (b: number) => ({ guards: { cre: { verdict: 'veto' as const, rule: 'R1_TOTAL_SUPPLY', tx: H(b), block: b }, breakglass: {} }, chain: { status: 2, vetoCount: 1, proposer: OWNER, clearanceOf: null } }) |
| 808 | const set = [ME('1', 100, veto(101)), ME('5', 500, veto(502)), ME('7', 700)] |
| 809 | const pk = pickFor('evil-mint', set, names, 710) |
| 810 | ok(pk.replay?.id === '5' && pk.live?.id === '7', `the card replays its newest judged proposal (#${pk.replay?.id}) and shows a newer one in its cooldown as live (#${pk.live?.id})`) |
| 811 | ok(pickFor('evil-mint', set, names, 800).live === null && pickFor('evil-mint', [ME('7', 700, { dropped: true })], names, 710).live === null, 'a proposal past its cooldown without a verdict, or dropped in a reorg, is not live') |
| 812 | const V2 = (id: string, b: number, exec: boolean) => { |
| 813 | const p = mk(id, '0', IMPL.v2, '0', { proposeBlock: b, readyBlock: b + 25 }) |
| 814 | p.clearance = { evidenceHash: H(b + 1), clearer: RX, by: 'cre', tx: H(b + 1), block: b + 1 } |
| 815 | p.guards.cre = { verdict: 'pass', tx: H(b + 1), block: b + 1 } |
| 816 | if (exec) p.execute = { tx: H(b + 30), block: b + 30 } |
| 817 | p.chain = { status: exec ? 3 : 1, vetoCount: 0, proposer: OWNER, clearanceOf: H(b + 1) } |
| 818 | return p |
| 819 | } |
| 820 | const v2s = [V2('2', 200, true), V2('6', 600, false)] |
| 821 | ok(pickFor('v2', v2s, names).replay?.id === '2' && pickFor('v2', [V2('6', 600, false)], names).replay?.id === '6', 'the v2 card prefers the newest executed upgrade (clear, cooldown, execute), else the newest cleared one') |
| 822 | ok(labSatisfied('v2', pickFor('v2', v2s, names)) && !labSatisfied('v2', pickFor('v2', [V2('6', 600, false)], names)) && !labSatisfied('evil-mint', { replay: null, live: null }), 'the backfill keeps looking for an executed v2') |
| 823 | // on the fake Sepolia read above |
| 824 | const now = LAB_IDS.map((id) => `${id}:${pickNow(id).replay?.id ?? '-'}`).join(' ') |
| 825 | ok(pickNow('evil-mint').replay?.id === '1' && pickNow('v2').replay?.id === '4' && pickNow('evil-drain').replay?.id === '5' && pickNow('remove-receiver').replay?.id === '7' && pickNow('evil-basefee').replay === null, `picker on the fake Sepolia: ${now}`) |
| 826 | |
| 827 | // 10b. the picker's playback: arrival, a "judging" hold, the verdict, then the cooldown fast-forwarded |
| 828 | { |
| 829 | const p = base() |
| 830 | p.proposeTx = H(100) |
| 831 | p.clearance = { evidenceHash: H(0xc1), clearer: RX, by: 'cre', tx: H(102), block: 102 } |
| 832 | p.guards.cre = { verdict: 'pass', tx: H(102), block: 102 } |
| 833 | p.execute = { tx: H(127), block: 127 } |
| 834 | p.chain = { status: 3, vetoCount: 0, proposer: '0x1', clearanceOf: H(0xc1) } |
| 835 | const e = new AutoReplay(0, { msPerBlock: 600, firstDelayMs: 0, idleMs: 1000, gapMs: 0, reducedHoldMs: 100 }) |
| 836 | e.paused = true |
| 837 | e.playOne(0, p, false, { msPerBlock: 100, fastMsPerBlock: 10, gapMs: 0 }) |
| 838 | const vb = (ms: number) => e.frame(ms, () => p)?.vblock |
| 839 | ok(vb(450) === 102 && vb(505) === 103 && vb(715) === 124 && vb(725) === 125, 'picker playback: blocks before the verdict at the normal pace, the rest of the cooldown fast-forwarded') |
| 840 | ok(e.progress(0)?.f === 0 && e.progress(10_000)?.f === 1 && e.onDemand, 'picker playback reports its progress (0..1)') |
| 841 | const j = base() |
| 842 | j.guards.cre = { verdict: 'veto', rule: 'R1_TOTAL_SUPPLY', tx: H(101), block: 101 } |
| 843 | j.chain = { status: 2, vetoCount: 1, proposer: '0x1', clearanceOf: null } |
| 844 | e.playOne(0, j, false, { msPerBlock: 100, dwellMs: { judging: 1000 } }) |
| 845 | ok(vb(250) === 100 && vb(1250) === 100 && vb(1350) === 101, 'a verdict one block after the proposal still gets a "judging" hold') |
| 846 | const tl = buildTimeline(p)! |
| 847 | ok(stagesFor('ejected', false).join() === 'arrive,judge,veto' && stagesFor('cleared', true).join() === 'arrive,judge,clear,fast,exec', 'picker steps: arrive, judge, veto | clear, fast-forward, execute') |
| 848 | ok(stageOf('idle', 98, tl) === 'arrive' && stageOf('chamber', 101, tl) === 'judge' && stageOf('cleared', 102, tl) === 'clear' && stageOf('cleared', 110, tl) === 'fast' && stageOf('executed', 127, tl) === 'exec' && stageOf('ejected', 101, tl) === 'veto', 'picker step of each phase') |
| 849 | ok(LAB_PLAY.fastMsPerBlock! < LAB_PLAY.msPerBlock! && (LAB_PLAY.dwellMs?.judging ?? 0) > 0 && (LAB_PLAY.dwellMs?.verdict ?? 0) >= 3000, 'the picker holds on the verdict and fast-forwards the cooldown') |
| 850 | ok(fmtDay(Date.UTC(2026, 9, 20, 16, 30) / 1000) === '2026-10-21' && fmtDay(undefined) === null && fmtDay(0) === null, 'replay dates are venue dates (UTC+8)') |
| 851 | } |
| 852 | |
| 853 | // 10c. first-visit autoplay rules |
| 854 | const ctx: AutoplayCtx = { clean: false, pick: null, offline: false, ready: true, interacted: false, liveBusy: false } |
| 855 | ok(autoplayWanted(ctx), 'first visit: the page plays one replay on its own') |
| 856 | ok(!autoplayWanted({ ...ctx, clean: true }) && autoplayRuledOut({ ...ctx, clean: true }), 'no autoplay with ?clean=1 (recording)') |
| 857 | ok(!autoplayWanted({ ...ctx, pick: '4' }) && autoplayRuledOut({ ...ctx, pick: '4' }), 'no autoplay with ?p=<id>') |
| 858 | ok(!autoplayWanted({ ...ctx, offline: true }) && !autoplayWanted({ ...ctx, interacted: true }) && autoplayRuledOut({ ...ctx, interacted: true }), 'no autoplay offline (the loop plays) or after the viewer picked something') |
| 859 | ok(!autoplayWanted({ ...ctx, ready: false }) && !autoplayWanted({ ...ctx, liveBusy: true }) && !autoplayRuledOut({ ...ctx, liveBusy: true }) && !autoplayRuledOut({ ...ctx, ready: false }), 'autoplay waits for the data and for a live proposal to finish its cooldown') |
| 860 | ok(introWanted(ctx) && introWanted({ ...ctx, ready: false }) && introWanted({ ...ctx, ready: false, liveBusy: true }), 'intro: shown while the data loads and until the first-visit replay starts') |
| 861 | ok(!introWanted({ ...ctx, clean: true }) && !introWanted({ ...ctx, pick: '4' }) && !introWanted({ ...ctx, offline: true }) && !introWanted({ ...ctx, interacted: true }) && !introWanted({ ...ctx, liveBusy: true }), 'no intro with ?clean=1, ?p=, offline, after a pick, or when a live proposal is in its cooldown (live wins)') |
| 862 | ok(AUTOPLAY_CARD === 'evil-drain' && labIndexOfKey('1') === 0 && labIndexOfKey('7') === 6 && labIndexOfKey('8') === null && keyAction('5') === 'lab' && keyAction('o') === 'operator', 'autoplay card is evil-drain; keys 1-7 play the cards, O opens the operator panel') |
| 863 | |
| 864 | // 10d. the operator UI stays invisible: disabled, 404, network error, or no token |
| 865 | setLang('zh-CN', false) |
| 866 | const res = (status: number, body: unknown) => (async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch |
| 867 | const opHtml = () => { |
| 868 | render() |
| 869 | const h = html() + region('t-ctl') + region('lab') |
| 870 | return /data-act="(operator|opask|opgo)|class="lc-op|op-badge|实战发起/.test(h) |
| 871 | } |
| 872 | ok(JSON.stringify(parseOperatorInfo(404, null)) === JSON.stringify(OP_DISABLED) && !parseOperatorInfo(200, { enabled: false, auth: 'none', cards: ['v2'], cooldownBlocks: 3 }).enabled && !parseOperatorInfo(200, { enabled: true, auth: 'open', cards: ['v2'] }).enabled && !parseOperatorInfo(200, { enabled: true, auth: 'none', cards: ['0x01'] }).enabled, 'GET /api/operator: 404, enabled false, an unknown auth or no known card mean disabled') |
| 873 | const good = parseOperatorInfo(200, { enabled: true, auth: 'token', cards: ['v2', 'evil-mint', 'bogus'], cooldownBlocks: 3 }) |
| 874 | ok(good.enabled && good.auth === 'token' && good.cards.join() === 'evil-mint,v2' && good.cooldownBlocks === 3, 'GET /api/operator: known cards only, in picker order') |
| 875 | const TOKEN = 'test-operator-token-0123456789abcdef' |
| 876 | ok(!operatorVisible(OP_DISABLED, TOKEN) && !operatorVisible(good, null) && operatorVisible(good, TOKEN) && operatorVisible({ ...good, auth: 'none' }, null), 'operator UI: needs enabled AND (auth none OR a token)') |
| 877 | op.token = null |
| 878 | await loadOperator(res(404, { error: 'not found' })) |
| 879 | ok(!op.info.enabled && op.loaded && !opHtml(), 'a static deploy (404): nothing operator-related on the page') |
| 880 | await loadOperator((async () => { |
| 881 | throw new Error('offline') |
| 882 | }) as unknown as typeof fetch) |
| 883 | ok(!op.info.enabled && !opHtml(), 'the API unreachable: nothing operator-related on the page') |
| 884 | await loadOperator(res(200, { enabled: true, auth: 'token', cards: [...LAB_IDS], cooldownBlocks: 3 })) |
| 885 | ok(op.info.enabled && !opHtml(), 'enabled but no token in this browser: nothing operator-related on the page') |
| 886 | op.prompt = true |
| 887 | render() |
| 888 | ok(region('op-token').includes('data-form="optoken"') && region('op-token').includes('type="password"') && plain(region('op-token')).includes('口令只存在这台浏览器里') && !opHtml(), '?op=1: a small token prompt (password field), and still no operator controls until a token is saved') |
| 889 | await loadOperator(res(404, null)) |
| 890 | render() |
| 891 | ok(!op.prompt && region('op-token') === '', '?op=1 on a page without the operator API: no prompt at all') |
| 892 | await loadOperator(res(200, { enabled: true, auth: 'token', cards: [...LAB_IDS], cooldownBlocks: 3 })) |
| 893 | ui.help = true |
| 894 | render() |
| 895 | ok(!plain(region('help')).includes('实战发起面板') && plain(region('help')).includes('重播第 1–7 种攻击'), 'the help lists keys 1-7 for everyone, the operator key only for the operator') |
| 896 | ui.help = false |
| 897 | |
| 898 | // 10e. the token: ?op=<token> is kept in this browser and leaves the address bar at once |
| 899 | const t1 = tokenFromUrl(`http://localhost/?lang=en&op=${TOKEN}#top`) |
| 900 | ok(t1.token === TOKEN && t1.stripped === '/?lang=en#top' && !t1.stripped.includes(TOKEN), '?op=<token>: the token is read and the address loses it') |
| 901 | const t2 = tokenFromUrl('http://localhost/?op=1') |
| 902 | const t3 = tokenFromUrl('http://localhost/?op=short&p=4') |
| 903 | ok(t2.prompt && t2.token === null && t2.stripped === '/' && t3.token === null && t3.stripped === '/?p=4' && tokenFromUrl('http://localhost/?lang=en').stripped === null, '?op=1 asks for the token; a malformed token is dropped (and still stripped); no ?op leaves the address alone') |
| 904 | const t4 = tokenFromUrl(`http://localhost/?lang=en#op=${TOKEN}`) |
| 905 | const t5 = tokenFromUrl(`http://localhost/?op=1#op=${TOKEN}&x=1`) |
| 906 | ok( |
| 907 | t4.token === TOKEN && t4.stripped === '/?lang=en' && t5.token === TOKEN && t5.stripped === '/#x=1' && !t5.stripped.includes(TOKEN) && tokenFromUrl('http://localhost/#top').stripped === null && tokenFromUrl('http://localhost/#stop=1').stripped === null, |
| 908 | '#op=<token> (never sent to the server) is read the same way and leaves the address; #top or #stop=1 are left alone', |
| 909 | ) |
| 910 | { |
| 911 | const keep = { token: op.token, prompt: op.prompt } |
| 912 | op.token = null |
| 913 | op.prompt = false |
| 914 | const pub = ['upgrade-airlock.pages.dev', 'example.org', '', null, undefined, '127.0.0.1.nip.io'].every((h) => !mayOperate(h)) |
| 915 | const lo = ['127.0.0.1', 'localhost', '[::1]'].every((h) => mayOperate(h)) |
| 916 | op.prompt = true |
| 917 | const asked = mayOperate('upgrade-airlock.pages.dev') |
| 918 | op.prompt = false |
| 919 | op.token = TOKEN |
| 920 | const held = mayOperate('upgrade-airlock.pages.dev') |
| 921 | op.token = keep.token |
| 922 | op.prompt = keep.prompt |
| 923 | ok(pub && lo && asked && held, 'a public visitor never asks /api/operator; only a loopback host, ?op=1 or a saved token do') |
| 924 | } |
| 925 | { |
| 926 | const g = globalThis as unknown as Record<string, unknown> |
| 927 | const loc = g.location as { search: string; href: string } |
| 928 | const was = loc.href |
| 929 | const replaced: string[] = [] |
| 930 | g.history = { state: null, replaceState: (_s: unknown, _t: string, u: string) => void replaced.push(String(u)) } |
| 931 | loc.href = `http://localhost/?mode=airlock&op=${TOKEN}` |
| 932 | dom.mem.delete('airlock.op') |
| 933 | op.token = null |
| 934 | takeTokenFromUrl() |
| 935 | loc.href = was |
| 936 | ok(op.token === TOKEN && dom.mem.get('airlock.op') === TOKEN && replaced.length === 1 && replaced[0] === '/?mode=airlock' && !replaced[0].includes(TOKEN), 'page load: the token goes to localStorage and history.replaceState removes it from the URL') |
| 937 | } |
| 938 | ok(opHtml(), 'with the token, the operator UI appears') |
| 939 | render() |
| 940 | const opAsk = [...region('lab').matchAll(/data-act="opask:([a-z0-9-]+)"/g)].map((m) => m[1]) |
| 941 | ok(region('t-ctl').includes('data-act="operator"') && opAsk.join(',') === LAB_IDS.join(','), `operator: the bolt button in the top bar and a launch button on each of the 7 cards (${opAsk.length})`) |
| 942 | op.panel = true |
| 943 | op.confirm = 'evil-mint' |
| 944 | render() |
| 945 | const panel = plain(region('op')) |
| 946 | ok(lack(['在 Sepolia 上真实发起攻击', '确定在 Sepolia 上发起「任意增发」?', '这会送出一笔真实交易(proposeUpgrade)', '确认发起', '预期:否决 R1', 'proposeSetGuardian(AirlockReceiver, false)', '两次发起至少间隔 3 块'], panel) === '' && region('op').includes('data-act="opgo:evil-mint"') && (region('op').match(/data-act="opgo:/g) ?? []).length === 1, `operator panel: one confirm step before a real launch${lack(['确认发起'], panel)}`) |
| 947 | setLang('en', false) |
| 948 | render() |
| 949 | ok(region('op').includes('Launch "Infinite mint" on Sepolia?') && !CJK.test(plain(region('op'))), 'operator panel in English') |
| 950 | setLang('zh-CN', false) |
| 951 | |
| 952 | // 10f. a launch: exactly {card} with the bearer token, then the war room follows the transaction on chain |
| 953 | const calls: { url: string; init: RequestInit }[] = [] |
| 954 | const LTX = H32('launch tx') |
| 955 | const okFetch = (async (url: string, init: RequestInit) => { |
| 956 | calls.push({ url, init }) |
| 957 | return new Response(JSON.stringify({ ok: true, card: 'evil-mint', txHash: LTX }), { status: 200 }) |
| 958 | }) as unknown as typeof fetch |
| 959 | let sent = 0 |
| 960 | ok((await launchCard('evil-owner' as never, store.proposals.values(), () => sent++, okFetch))?.ok === true, 'a launch is sent') |
| 961 | const c0 = calls[0] |
| 962 | const hdr = c0.init.headers as Record<string, string> |
| 963 | ok(c0.url === '/api/operator/propose' && c0.init.method === 'POST' && c0.init.body === '{"card":"evil-owner"}' && hdr.authorization === `Bearer ${TOKEN}` && !c0.url.includes(TOKEN), 'POST /api/operator/propose with only {card} and the bearer token in a header') |
| 964 | ok(sent === 1 && op.launch?.state === 'sent' && op.launch.txHash === LTX && launchStage(op.launch, store.proposals.values()).stage === 'sent', 'after the server answers: the tx hash, pending') |
| 965 | ok((await launchCard('v2', store.proposals.values(), () => sent++, okFetch)) === null && calls.length === 1, 'one launch at a time: a second one waits until the first is on chain') |
| 966 | op.panel = false |
| 967 | render() |
| 968 | ok(plain(region('op-toast')).includes('交易已送出 · 等待上链') && region('op-toast').includes(`https://sepolia.etherscan.io/tx/${LTX}`), 'the status toast: tx sent, with its Etherscan link') |
| 969 | ok(plain(region('h-head')).includes('操作员刚刚发起 · 真实交易') && plain(region('h-head')).includes('抢走 owner') && region('h-head').includes('tag live') && region('h-count').includes(`https://sepolia.etherscan.io/tx/${LTX}`) && plain(region('v-main')).includes('等待上链') && !region('h-lock').includes('ph-ejected'), 'while the launch is on its way into a block, the airlock shows it as pending (live), not the previous final verdict') |
| 970 | HEAD = 1330 |
| 971 | const NOHOOK = { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive: (ts: number) => replay.onLive(ts) } |
| 972 | handleLog(mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 10n, kind: 0, target: IMPL.evilOwner, value: 0n, data: '0x', codehash: H32('c10'), readyBlock: 1325n }, 1300, LTX, 0), true, NOHOOK) |
| 973 | blocks.ingest(1301) |
| 974 | render() |
| 975 | ok(store.activeId === '10' && plain(region('op-toast')).includes('提案 #10 已进气闸 · 等待 Chainlink CRE 判定') && region('h-head').includes('tag live') && plain(region('h-head')).includes('实时') && !region('h-head').includes('tag replay'), 'the proposal lands: auto-follow shows it, labelled live (not a replay); the toast says it is in the airlock') |
| 976 | ok(region('lab').includes(`实时 #10`) && plain(region('t-status')).includes('实时 · Ethereum Sepolia'), 'the picker card of that attack says live #10; the top bar says live') |
| 977 | // a visitor starts a picker replay of an older proposal meanwhile; the live verdict of #10 then takes the screen back |
| 978 | const pickedReplay = playLab('evil-mint') && !!labPlaying() && store.activeId !== '10' |
| 979 | handleLog(mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 10n, guardian: RECV, ruleHash: ruleHashOf('R2_OWNER'), vetoCount: 1n }, 1302, H32('veto 10'), 0), true, NOHOOK) |
| 980 | ok(pickedReplay && !labPlaying() && store.activeId === '10', 'auto-follow: a live verdict on the newest proposal stops a picker replay and shows that proposal, not the replayed one') |
| 981 | render() |
| 982 | ok(plain(region('op-toast')).includes('提案 #10 已否决 · Chainlink CRE · R2'), `the verdict lands in the toast: ${plain(region('op-toast')).trim()}`) |
| 983 | const fail = (status: number, body: unknown) => launchCard('v2', store.proposals.values(), () => sent++, res(status, body)) |
| 984 | await fail(409, { ok: false, error: 'cooldown', retryAfterBlocks: 2 }) |
| 985 | render() |
| 986 | ok(op.launch?.error === 'cooldown' && plain(region('op-toast')).includes('冷却中 · 再等 2 块(约 24 秒)'), '409: cooling down, with the blocks to wait') |
| 987 | await fail(422, { ok: false, error: 'reverted', detail: 'secret internals' }) |
| 988 | render() |
| 989 | ok(op.launch?.error === 'reverted' && !region('op-toast').includes('secret internals'), '422: the dry run reverted; the server words are never shown') |
| 990 | // every answer of the API contract maps to its own error, and every error to its own sentence in each language |
| 991 | const errOf = (st: number, body: unknown) => (parseLaunchResponse(st, body) as { error?: string }).error |
| 992 | const table: [number, unknown, string][] = [ |
| 993 | [200, { ok: true, card: 'v2', txHash: '0x12' }, 'unclear'], |
| 994 | [200, { ok: true, card: 'not-a-card', txHash: LTX }, 'unclear'], |
| 995 | [400, { ok: false, error: 'unknown card' }, 'unknown card'], |
| 996 | [401, { ok: false, error: 'unauthorized' }, 'unauthorized'], |
| 997 | [403, { ok: false, enabled: false, error: 'disabled' }, 'disabled'], |
| 998 | [403, { ok: false, error: 'forbidden' }, 'forbidden'], |
| 999 | [404, null, 'disabled'], |
| 1000 | [405, { ok: false, error: 'method' }, 'server'], |
| 1001 | [409, { ok: false, error: 'cooldown', retryAfterBlocks: 2 }, 'cooldown'], |
| 1002 | [422, { ok: false, error: 'reverted' }, 'reverted'], |
| 1003 | [500, { ok: false, error: 'misconfigured' }, 'misconfigured'], |
| 1004 | [500, { ok: false, error: 'internal' }, 'server'], |
| 1005 | [502, { ok: false, error: 'rpc' }, 'rpc'], |
| 1006 | ] |
| 1007 | const wrong = table.filter(([st, b, e]) => errOf(st, b) !== e).map(([st, b]) => `${st}:${JSON.stringify(b)}->${errOf(st, b)}`) |
| 1008 | ok(wrong.length === 0, `launch answers: each status of the API contract has its own error (${wrong.join(' ') || '13 cases'})`) |
| 1009 | const ERRS = ['unknown card', 'unauthorized', 'disabled', 'forbidden', 'cooldown', 'reverted', 'misconfigured', 'rpc', 'server', 'unclear', 'network'] as const |
| 1010 | for (const l of ['zh-CN', 'zh-TW', 'en'] as Lang[]) { |
| 1011 | setLang(l, false) |
| 1012 | const texts = ERRS.map((e) => launchErrorText({ error: e })) |
| 1013 | ok(new Set(texts).size === ERRS.length && texts.every((x) => x.length > 4) && (l !== 'en' || !texts.some((x) => CJK.test(x))) && (l !== 'zh-TW' || !texts.some((x) => SIMPLIFIED_ONLY.test(x))), `${l}: every launch error has its own wording`) |
| 1014 | } |
| 1015 | setLang('zh-CN', false) |
| 1016 | const toast = () => { |
| 1017 | render() |
| 1018 | return plain(region('op-toast')) |
| 1019 | } |
| 1020 | await fail(500, { ok: false, error: 'misconfigured' }) |
| 1021 | ok(op.launch?.error === 'misconfigured' && toast().includes('服务器上的私钥不是这个部署的 owner · 没有送出') && opHtml(), '500 misconfigured: the wrong key is named as such (not a network problem)') |
| 1022 | await fail(403, { ok: false, error: 'forbidden' }) |
| 1023 | ok(toast().includes('操作员服务拒绝了这个来源') && opHtml(), '403 forbidden (origin guard): said so; operator mode stays') |
| 1024 | await fail(502, { ok: false, error: 'rpc' }) |
| 1025 | ok(toast().includes('节点没有接受这笔交易'), '502: the node did not take it') |
| 1026 | await fail(500, { ok: false, error: 'internal' }) |
| 1027 | ok(toast().includes('操作员服务出错 · 没有送出'), '500 internal: the service failed') |
| 1028 | await fail(200, { ok: true }) |
| 1029 | ok(toast().includes('回应看不懂 · 若交易已送出,战情室会自动接上'), 'a 200 without a tx hash: unclear, the live war room picks it up if it went out') |
| 1030 | await fail(400, { ok: false, error: 'unknown card' }) |
| 1031 | ok(toast().includes('这张卡不能发起'), '400: this card cannot be launched') |
| 1032 | await launchCard('v2', store.proposals.values(), () => sent++, (async () => { |
| 1033 | throw new Error('offline') |
| 1034 | }) as unknown as typeof fetch) |
| 1035 | ok(op.launch?.error === 'network' && toast().includes('连不上操作员服务'), 'no answer at all: cannot reach the operator service') |
| 1036 | ok(sent === 1, 'none of the refused launches counted as sent') |
| 1037 | await fail(401, { ok: false, error: 'unauthorized' }) |
| 1038 | ok(op.token === null && !dom.mem.has('airlock.op') && !opHtml() && plain(region('op-toast')).includes('操作员口令无效'), '401: the token is forgotten, the operator UI disappears, a notice says why') |
| 1039 | op.notice = null |
| 1040 | op.launch = null |
| 1041 | await loadOperator(res(200, { enabled: true, auth: 'none', cards: [...LAB_IDS], cooldownBlocks: 3 })) |
| 1042 | ok(opHtml(), 'local dev (auth none): the operator UI without a token') |
| 1043 | await fail(403, { ok: false, enabled: false, error: 'disabled' }) |
| 1044 | ok(op.launch?.error === 'disabled' && !opHtml() && plain(region('op-toast')).includes('这里的操作员模式已关闭'), '403 disabled: operator mode was switched off on the server; its UI goes, a notice says why') |
| 1045 | op.notice = null |
| 1046 | op.launch = null |
| 1047 | await loadOperator(res(200, { enabled: true, auth: 'none', cards: [...LAB_IDS], cooldownBlocks: 3 })) |
| 1048 | ok(opHtml(), 'and back when the API says enabled again') |
| 1049 | await loadOperator(res(404, null)) |
| 1050 | store.proposals.delete('10') |
| 1051 | store.arrival = null |
| 1052 | pickActive() |
| 1053 | ok(!opHtml(), 'and gone again when the API says disabled') |
| 1054 | |
| 1055 | // 10g. first-visit autoplay on the page: evil-drain plays from its arrival, labelled as a replay of a real proposal |
| 1056 | blockTs.set(1240, blockTime(1240)) |
| 1057 | const realNow = Date.now |
| 1058 | let NOW = realNow() |
| 1059 | Date.now = () => NOW |
| 1060 | try { |
| 1061 | const T0 = NOW |
| 1062 | lab.autoplay = 'wait' |
| 1063 | lab.readyAt = 0 |
| 1064 | lab.interacted = false |
| 1065 | lab.invite = null |
| 1066 | tickLab(T0) |
| 1067 | ok(lab.autoplay === 'wait' && !labPlaying(T0), 'autoplay waits a moment after the data is in') |
| 1068 | render() |
| 1069 | ok(labIntro(T0) && plain(region('h-head')).includes('正在载入一笔真实攻击') && !region('h-lock').includes('ph-ejected') && !region('h-lock').includes('ph-executed'), 'meanwhile the intro is up, not a final verdict') |
| 1070 | NOW = T0 + AUTOPLAY_DELAY_MS + 10 |
| 1071 | tickLab(NOW) |
| 1072 | ok(lab.autoplay === 'playing' && lab.card === 'evil-drain' && labPlaying(NOW)?.pid === '5', `autoplay: the evil-drain card plays proposal #${labPlaying(NOW)?.pid}`) |
| 1073 | render() |
| 1074 | const day = fmtDay(blockTime(1240))! |
| 1075 | ok(plain(region('h-head')).includes(`回放 · 真实 Sepolia 提案 · ${day}`) && plain(region('t-status')).includes('回放 · Sepolia 提案 #5'), `replay label: "回放 · 真实 Sepolia 提案 · ${day}" in the hero, "回放 · Sepolia 提案 #5" in the top bar`) |
| 1076 | ok(plain(region('lab')).includes(`正在回放 #5 · 真实 Sepolia 提案 · ${day}`) && region('lab').includes(`https://sepolia.etherscan.io/tx/${T('p5')}`) && region('lab').includes('class="lc k-veto on'), 'the picker says which real proposal is playing, with its proposal tx link') |
| 1077 | ok(region('h-lock').includes('ph-idle') || region('h-lock').includes(' m-arrive'), 'the replay starts before the proposal arrives (outer door)') |
| 1078 | ok(!labIntro(NOW) && !plain(region('h-head')).includes('正在载入一笔真实攻击'), 'the intro is gone once the replay runs') |
| 1079 | ok(plain(region('h-count')).includes('提案 #5 抵达外门') || region('h-lock').includes(' m-arrive'), 'the replay lead-in says the proposal is reaching the outer door') |
| 1080 | const seen = new Set<string>() |
| 1081 | let vetoShot = '' |
| 1082 | const curs: number[] = [] |
| 1083 | const chips: string[] = [] |
| 1084 | let vTag = 0 |
| 1085 | let compressed = 0 |
| 1086 | let lastF = -1 |
| 1087 | let monotonic = true |
| 1088 | for (let i = 0; i < 600 && labPlaying(NOW); i++) { |
| 1089 | NOW += 100 |
| 1090 | tickLab(NOW) |
| 1091 | tickReplay(NOW) |
| 1092 | const lp = labPlaying(NOW) |
| 1093 | if (lp) seen.add(lp.stage) |
| 1094 | const f = judgeProgress(NOW) |
| 1095 | if (f !== null) { |
| 1096 | if (f < lastF) monotonic = false |
| 1097 | lastF = f |
| 1098 | } |
| 1099 | if (lp?.stage === 'judge') { |
| 1100 | render() |
| 1101 | const m = /CRE · ([^<]+?) (\d)\/(\d)/.exec(plain(region('h-count'))) |
| 1102 | if (m) chips.push(`${m[2]}/${m[3]}`) |
| 1103 | if (region('v-head').includes('v-rp')) vTag++ |
| 1104 | if (plain(region('h-count')).includes('回放 · 时间已压缩')) compressed++ |
| 1105 | const pipe = region('v-pipe') |
| 1106 | if (pipe.includes('pipe st-run') && pipe.includes(' seq')) curs.push((pipe.match(/class="ok"/g) ?? []).length) |
| 1107 | } |
| 1108 | if (lp?.stage === 'veto' && !vetoShot) { |
| 1109 | render() |
| 1110 | vetoShot = html() |
| 1111 | } |
| 1112 | } |
| 1113 | ok(monotonic && lastF === 1, 'judgeProgress runs from the arrival (0) to the verdict (1)') |
| 1114 | ok(curs.length > 5 && curs.every((x, i) => i === 0 || x >= curs[i - 1]) && curs[0] === 0 && Math.max(...curs) >= 3, `while CRE judges, the verdict card walks through the workflow steps one by one (${[...new Set(curs)].join(' → ')} done)`) |
| 1115 | ok(['arrive', 'judge', 'veto'].every((x) => seen.has(x)) && vetoShot.includes('class="lk ph-ejected') && vetoShot.includes('pipe st-done k-veto'), `the replay walks through ${[...seen].join(' → ')} and ends ejected`) |
| 1116 | ok(chips.length > 5 && chips[0] === '1/5' && new Set(chips).size >= 3 && vTag === chips.length, `phone-sized proof: the chamber chip walks the same CRE steps (${[...new Set(chips)].join(' → ')}), the verdict card says "replay"`) |
| 1117 | ok(compressed === chips.length && compressed > 0, 'next to the still counter, the replay says its clock is compressed') |
| 1118 | tickLab(NOW) |
| 1119 | render() |
| 1120 | ok(lab.autoplay === 'done' && lab.invite === 'evil-drain' && plain(region('lab')).includes('刚才是「搬走持有人的币」的真实重播') && (region('lab').match(/ nudge"/g) ?? []).length === 6, 'after the autoplay: the picker invites the viewer to pick another card') |
| 1121 | ok(store.activeId === '5' && !region('h-head').includes('tag replay'), 'after the replay the screen holds the real final state of that proposal') |
| 1122 | ok(plain(region('h-head')).includes(`链上状态 · 提案于 ${day}`) && !region('h-head').includes('tag live'), `and says it is a dated on-chain state ("链上状态 · 提案于 ${day}"), never live`) |
| 1123 | ok(playLab('v2', NOW) && labPlaying(NOW)?.pid === '4' && lab.invite === null && lab.interacted, 'a click on a card plays it (v2 -> the executed #4) and ends the invite') |
| 1124 | let exec = false |
| 1125 | let ff = false |
| 1126 | for (let i = 0; i < 600 && labPlaying(NOW); i++) { |
| 1127 | NOW += 100 |
| 1128 | tickReplay(NOW) |
| 1129 | if (labPlaying(NOW)?.stage === 'fast' && !ff) { |
| 1130 | render() |
| 1131 | ff = plain(region('h-count')).includes('冷静期快进 ×80') |
| 1132 | } |
| 1133 | if (labPlaying(NOW)?.stage === 'exec' && !exec) { |
| 1134 | exec = true |
| 1135 | render() |
| 1136 | ok(region('h-lock').includes('ph-executed') && plain(html()).includes('已执行 · DemoBTC v2 上线') && region('lab').includes('st-exec'), 'v2 replay reaches the inner door: executed, the upgrade is live') |
| 1137 | } |
| 1138 | } |
| 1139 | ok(exec, 'the v2 replay played through to its execution') |
| 1140 | ok(ff, 'the fast-forwarded cooldown is marked in the chamber (冷静期快进 ×80)') |
| 1141 | } finally { |
| 1142 | Date.now = realNow |
| 1143 | } |
| 1144 | |
| 1145 | // 10g2. a cold visitor: the evil-drain proposal is read first, so its replay can start before the others are in; |
| 1146 | // a chain that does not answer turns the intro into an honest "slow" line with the recorded run |
| 1147 | { |
| 1148 | ok( |
| 1149 | loadOrder(['1', '2', '3', '4', '5', '6', '7', '8'], (id) => (({ '8': 'evil-drain', '2': 'evil-drain', '1': 'evil-mint', '4': 'v2' }) as Record<string, LabId>)[id] ?? null).join() === '8,1,4,7,6,5,3,2', |
| 1150 | 'load order: the autoplay card\'s newest proposal first, then each other card\'s newest, then the rest newest first', |
| 1151 | ) |
| 1152 | ok(loadOrder(['3', '1'], () => null).join() === '3,1' && loadOrder([], () => null).length === 0, 'load order without picker cards: newest first') |
| 1153 | const keep = { ready: playbackGate.ready, loaded: new Set(playbackGate.loaded), startedAt: playbackGate.startedAt } |
| 1154 | const st = { autoplay: lab.autoplay, readyAt: lab.readyAt, interacted: lab.interacted, invite: lab.invite } |
| 1155 | const drainId = pickNow('evil-drain').replay?.id ?? '' |
| 1156 | playbackGate.ready = false |
| 1157 | playbackGate.loaded.clear() |
| 1158 | ok(!autoplayDataIn() && !historyIn(drainId) && !playLab('evil-drain', Date.now(), false), 'nothing read yet: no replay can start') |
| 1159 | playbackGate.loaded.add(drainId) |
| 1160 | ok(autoplayDataIn() && historyIn(drainId) && !historyIn('1'), `the autoplay proposal #${drainId} alone is enough for the first-visit replay; the other cards still wait`) |
| 1161 | lab.autoplay = 'wait' |
| 1162 | lab.readyAt = 0 |
| 1163 | lab.interacted = false |
| 1164 | playbackGate.loaded.clear() |
| 1165 | playbackGate.startedAt = Date.now() - INTRO_SLOW_MS - 1000 |
| 1166 | render() |
| 1167 | ok(introSlow() && plain(region('h-head')).includes('连接 Sepolia 比较慢') && region('h-head').includes('mode=offline') && plain(region('h-count')).includes('仍在读取链上数据'), 'no answer for 12 s: the intro says the public RPCs are slow and links the recorded run (?mode=offline)') |
| 1168 | playbackGate.startedAt = Date.now() |
| 1169 | ok(!introSlow(), 'and not before 12 s') |
| 1170 | playbackGate.ready = keep.ready |
| 1171 | playbackGate.loaded.clear() |
| 1172 | for (const x of keep.loaded) playbackGate.loaded.add(x) |
| 1173 | playbackGate.startedAt = keep.startedAt |
| 1174 | Object.assign(lab, st) |
| 1175 | render() |
| 1176 | } |
| 1177 | |
| 1178 | // 10h. a card whose newest judged proposal is older than the recent ones: the backfill reads it |
| 1179 | { |
| 1180 | HEAD = 1400 |
| 1181 | NEXT = 31 |
| 1182 | PROPS.set(30, prop(IMPL.evilFee, 2, 1350, 1n)) |
| 1183 | LOGS.push( |
| 1184 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 30n, kind: 0, target: IMPL.evilFee, value: 0n, data: '0x', codehash: H32('c30'), readyBlock: 1375n }, 1350, T('p30'), 0), |
| 1185 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 30n, guardian: RECV, ruleHash: ruleHashOf('R3_TRANSFER_CONSERVATION'), vetoCount: 1n }, 1352, T('v30'), 0), |
| 1186 | ) |
| 1187 | const before = rpcCalls.filter((c) => c.method === 'eth_call').length |
| 1188 | ok(!pickNow('evil-fee').replay, 'evil-fee has no proposal in the store yet') |
| 1189 | const found = await backfillLab(31, 40) |
| 1190 | const reads = rpcCalls.filter((c) => c.method === 'eth_call').length - before |
| 1191 | ok(found.includes('30') && pickNow('evil-fee').replay?.id === '30' && P('30').guards.cre.rule === 'R3_TRANSFER_CONSERVATION', `backfill: older proposal #30 (EvilFee, R3) found and its logs read (${reads} eth_calls; found ${found.join(',')}; rule ${P('30')?.guards.cre.rule}; pick ${pickNow('evil-fee').replay?.id}; card ${P('30')?.card})`) |
| 1192 | ok(reads <= 2 * 40 + 2, 'backfill reads at most labScanMax older ids') |
| 1193 | store.proposals.delete('30') |
| 1194 | resetSeen() |
| 1195 | } |
| 1196 | |
| 1197 | // 10i. ?p=<id> of a proposal older than the recent ones: it is read with its logs; an id the chain does not have |
| 1198 | // says so (never "the airlock is empty") |
| 1199 | { |
| 1200 | PROPS.set(25, prop(IMPL.evilOwner, 2, 1360, 1n)) |
| 1201 | LOGS.push( |
| 1202 | mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 25n, kind: 0, target: IMPL.evilOwner, value: 0n, data: '0x', codehash: H32('c25'), readyBlock: 1385n }, 1360, T('p25'), 0), |
| 1203 | mkLog(ADMIN_ABI, ADMIN, 'Vetoed', { id: 25n, guardian: RECV, ruleHash: ruleHashOf('R2_OWNER'), vetoCount: 1n }, 1361, T('v25'), 0), |
| 1204 | ) |
| 1205 | const keep = { active: store.activeId, follow: store.follow } |
| 1206 | ok(!store.proposals.has('25') && !playbackGate.loaded.has('25'), '?p=25 is not among the recent proposals') |
| 1207 | const got = await loadPicked('25', 31, 1400) |
| 1208 | ok(got && P('25')?.proposeTx === T('p25') && P('25').chain?.status === 2 && P('25').guards.cre.block === 1361 && P('25').guards.cre.rule === 'R2_OWNER' && playbackGate.loaded.has('25'), '?p=25 outside the recent window: that proposal and its logs are read (it can be shown and replayed)') |
| 1209 | const before = rpcCalls.length |
| 1210 | const none = await loadPicked('99', 31, 1400) |
| 1211 | ok(!none && !store.proposals.has('99') && rpcCalls.length === before, '?p=99 past nextId: no such proposal, nothing read and nothing invented') |
| 1212 | store.proposals.delete('25') |
| 1213 | playbackGate.loaded.delete('25') |
| 1214 | chainHealth.pickMissing = '99' |
| 1215 | store.activeId = '99' |
| 1216 | setManualPick(true) |
| 1217 | setLang('en', false) |
| 1218 | render() |
| 1219 | const en = plain(region('h-head')) |
| 1220 | setLang('zh-CN', false) |
| 1221 | render() |
| 1222 | const zh = plain(region('h-head')) |
| 1223 | ok(en.includes('No proposal #99') && en.includes('The newest proposal on Ethereum Sepolia is #7') && !en.includes('The airlock is empty') && zh.includes('没有提案 #99') && !zh.includes('气闸空着'), `the hero says there is no such proposal (${en.trim().slice(0, 90)})`) |
| 1224 | chainHealth.pickMissing = null |
| 1225 | setManualPick(false) |
| 1226 | store.activeId = keep.active |
| 1227 | store.follow = keep.follow |
| 1228 | pickActive() |
| 1229 | resetSeen() |
| 1230 | } |
| 1231 | |
| 1232 | // 10j. the invite after the first-visit replay goes as soon as a live proposal is on screen (one sent from the CLI or |
| 1233 | // another device takes the screen without the viewer touching the page) |
| 1234 | { |
| 1235 | const keepFollow = store.follow |
| 1236 | store.follow = true |
| 1237 | setManualPick(false) |
| 1238 | lab.autoplay = 'done' |
| 1239 | lab.invite = 'evil-drain' |
| 1240 | render() |
| 1241 | const up = region('lab').includes('class="invite"') && plain(region('lab')).includes('刚才是') |
| 1242 | HEAD = 1405 |
| 1243 | PROPS.set(31, prop(IMPL.evilMint, 1, 1402)) |
| 1244 | handleLog(mkLog(ADMIN_ABI, ADMIN, 'Proposed', { id: 31n, kind: 0, target: IMPL.evilMint, value: 0n, data: '0x', codehash: H32('c31'), readyBlock: 1427n }, 1402, T('p31'), 0), true, { wantReceipt() {}, wantBlockTs() {}, refresh() {}, onLive: (ts) => replay.onLive(ts) }) |
| 1245 | blocks.ingest(1403) |
| 1246 | tickLab() |
| 1247 | render() |
| 1248 | ok(up && store.activeId === '31' && lab.invite === null && !region('lab').includes('class="invite"') && !plain(region('lab')).includes('刚才是') && region('h-head').includes('tag live'), 'a live proposal takes the screen: the "That was a real replay…" heading goes at once, never next to a live attack') |
| 1249 | store.proposals.delete('31') |
| 1250 | PROPS.delete(31) |
| 1251 | store.arrival = null |
| 1252 | store.follow = keepFollow |
| 1253 | pickActive() |
| 1254 | } |
| 1255 | lab.card = null |
| 1256 | lab.pid = null |
| 1257 | replay.stop(Date.now()) |
| 1258 | render() |
| 1259 | const css = readFileSync(join(WEB, 'src', 'style.css'), 'utf8') |
| 1260 | ok(/\.no-lab \.lab \{\s*display: none;/.test(css) && /\.stage:not\(\.no-lab\):not\(\.tab-recent\) \.strip,\s*\.stage\.tab-recent \.lab \{\s*display: none;/.test(css), '?clean=1 has no picker; a stage screen shows the picker or the strip (tabs)') |
| 1261 | ok(/\.lab-cards \{[^}]*grid-template-columns: repeat\(7, minmax\(0, 1fr\)\)/.test(css) && /\.lab-cards \{[^}]*grid-auto-flow: column;[^}]*overflow-x: auto/.test(css), 'stage: seven cards in a row; phone: a row of cards to swipe') |
| 1262 | } |
| 1263 | |
| 1264 | // ---------- 9. identity: nothing in web/ names another project or a local path ---------- |
| 1265 | { |
| 1266 | const hits = scanWeb(WEB) |
| 1267 | ok(hits.length === 0, `web/ sources are clean of blocked names and local paths (${NAMES_NOTE})${hits.length ? ': ' + hits.map((x) => `${x.file} (${x.hits.join(', ')})`).join('; ') : ''}`) |
| 1268 | ok(findBlocked(html()).length === 0, 'rendered page is clean too') |
| 1269 | } |
| 1270 | |
| 1271 | console.log(fails ? `\n${fails} FAILED` : '\nall passed') |
| 1272 | process.exit(fails ? 1 : 0) |