Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / chainwatch.ts
web/src/chainwatch.ts414 lines · 19037 bytes
1// Live mode: the browser reads Ethereum Sepolia (or a local fork via ?rpc=) directly.
2// - WS subscription to the logs of AirlockAdmin + AirlockReceiver (block headers come from chain.ts);
3// - an eth_getLogs cursor fills gaps whether or not the WS is up (each call <= CHAIN.logRange blocks);
4// - on page open the newest proposals are loaded with getProposal + clearanceOf, and each one's logs are read
5// from its proposal block on (CHAIN.historySpan blocks), so the page has content before anything happens live;
6// - verdict cost = receipt gasUsed x effectiveGasPrice; "N seconds after the proposal" uses block timestamps;
7// - the attack picker (lab.ts) needs each card's newest judged proposal: when one is older than the recent ones,
8// older ids are read too (backfillLab), at most CONFIG.labScanMax of them;
9// - ?p=<id> of a proposal older than the recent ones: that one is read too, first (loadPicked).
10// Only eth_call / eth_getLogs / eth_getTransactionReceipt / eth_getBlockByNumber: nothing is signed or sent.
11import { decodeFunctionResult, encodeFunctionData, numberToHex, pad, toEventSelector, type Hex } from 'viem'
12import { ADMIN_ABI, RECEIVER_ABI } from './abi'
13import { blocks, rpc } from './chain'
14import { DATA, type RawLog } from './chaindata'
15import { CONFIG } from './config'
16import { blockTs, handleLog, loadImplNames, pickActive, type LogHooks } from './events'
17import { msg } from './i18n'
18import { playbackGate, replay } from './playback'
19import { CHAIN, PICK } from './runtime'
20import { newProposal, store, type StreamLine } from './store'
21import { isZero32 } from './types'
22import { AUTOPLAY_CARD, LAB_IDS, labIdOf, labSatisfied, pickFor, type LabId } from './lab'
23
24const ADMIN = DATA.airlockAdmin as Hex
25const RX = DATA.receiver as Hex
26const ADDRS: Hex[] = [ADMIN, RX]
27const ADMIN_EVENTS = ['Proposed', 'Vetoed', 'Cleared', 'Executed', 'ReasonAttached'] as const
28const topicOf = (abi: readonly unknown[], name: string): Hex =>
29 toEventSelector((abi as readonly { type: string; name?: string }[]).find((x) => x.type === 'event' && x.name === name) as never)
30/** topic0 of every log the page reads (one getLogs call covers both contracts) */
31export const TOPICS: Hex[] = [...ADMIN_EVENTS.map((e) => topicOf(ADMIN_ABI, e)), topicOf(RECEIVER_ABI, 'Verdict')]
32
33/** Health of the log feed (top bar). pickMissing: the ?p=<id> the chain has no proposal for (yet), so the hero says
34 * "no proposal #id" instead of "the airlock is empty" */
35export const chainHealth = { logsLink: 'down' as 'ws' | 'poll' | 'down', cursor: 0, lastError: '', loadedRecent: false, pickMissing: null as string | null }
36
37// ---------- HTTP JSON-RPC: rotate endpoints, 8 s timeout, up to 4 tries ----------
38// Two rotations: the head (calls, block numbers, receipts, the live log cursor) and history (eth_getLogs of past
39// proposals: CHAIN.historyHttp, since one public endpoint prunes old logs)
40const rot = { head: 0, history: 0 }
41type Pool = keyof typeof rot
42const poolUrls = (pool: Pool) => (pool === 'history' && CHAIN.historyHttp.length ? CHAIN.historyHttp : CHAIN.http)
43async function call<T>(method: string, params: unknown[], tries = 4, pool: Pool = 'head'): Promise<T> {
44 let last: unknown
45 const urls = poolUrls(pool)
46 for (let i = 0; i < tries; i++) {
47 const url = urls[rot[pool] % urls.length]
48 try {
49 return await rpc<T>(url, method, params, 8000)
50 } catch (e) {
51 last = e
52 rot[pool]++
53 chainHealth.lastError = `${method}: ${(e as Error).message}`
54 await new Promise((r) => setTimeout(r, 300 * (i + 1)))
55 }
56 }
57 throw last instanceof Error ? last : new Error(String(last))
58}
59
60async function readAdmin<T>(functionName: 'getProposal' | 'clearanceOf' | 'nextId', args: unknown[] = []): Promise<T> {
61 const data = encodeFunctionData({ abi: ADMIN_ABI, functionName, args } as never)
62 const ret = await call<Hex>('eth_call', [{ to: ADMIN, data }, 'latest'])
63 return decodeFunctionResult({ abi: ADMIN_ABI, functionName, data: ret } as never) as T
64}
65
66interface ChainProposal {
67 kind: number
68 status: number
69 proposer: string
70 target: string
71 value: bigint
72 codehash: string
73 proposedBlock: bigint
74 readyBlock: bigint
75 vetoCount: bigint
76}
77
78// ---------- block timestamps and receipts (background reads; the page redraws when they land) ----------
79const tsBusy = new Set<number>()
80function wantBlockTs(n: number | null | undefined) {
81 if (!n || blockTs.has(n) || tsBusy.has(n)) return
82 tsBusy.add(n)
83 call<{ timestamp?: Hex } | null>('eth_getBlockByNumber', [numberToHex(n), false])
84 .then((b) => {
85 if (b?.timestamp) {
86 blockTs.set(n, parseInt(b.timestamp, 16))
87 store.emit()
88 }
89 })
90 .catch(() => {
91 /* the page falls back to "about N s at 12 s/block" */
92 })
93 .finally(() => setTimeout(() => tsBusy.delete(n), 5000))
94}
95
96const receiptBusy = new Set<string>()
97function wantReceipt(tx: string) {
98 const key = tx.toLowerCase()
99 if (store.receipts.has(key) || receiptBusy.has(key)) return
100 receiptBusy.add(key)
101 call<{ status: Hex; gasUsed: Hex; effectiveGasPrice?: Hex } | null>('eth_getTransactionReceipt', [tx])
102 .then((r) => {
103 if (!r) throw new Error('no receipt yet')
104 store.receipts.set(key, {
105 status: r.status === '0x1' ? 'success' : 'reverted',
106 gasUsed: BigInt(r.gasUsed).toString(),
107 effectiveGasPrice: r.effectiveGasPrice ? BigInt(r.effectiveGasPrice).toString() : undefined,
108 })
109 store.emit()
110 receiptBusy.delete(key)
111 })
112 .catch(() => setTimeout(() => receiptBusy.delete(key), 3000))
113}
114
115// ---------- getProposal + clearanceOf ----------
116function applyProposal(id: string, cp: ChainProposal, clearance: string | null) {
117 const p = store.ensure(id)
118 p.kind = String(cp.kind)
119 p.target = cp.target
120 p.value = String(cp.value)
121 p.codehash = cp.codehash
122 p.proposeBlock = Number(cp.proposedBlock)
123 p.readyBlock = Number(cp.readyBlock)
124 p.chain = { status: Number(cp.status), vetoCount: Number(cp.vetoCount), proposer: cp.proposer, clearanceOf: clearance && !isZero32(clearance) ? clearance : null }
125 const name = store.implNames.get(cp.target.toLowerCase())
126 if (name && name !== 'v1' && name !== 'paused') p.card = name
127 wantBlockTs(p.proposeBlock)
128}
129
130const refreshBusy = new Set<string>()
131const refreshAgain = new Set<string>()
132async function refreshProposal(id: string) {
133 if (refreshBusy.has(id)) {
134 refreshAgain.add(id)
135 return
136 }
137 refreshBusy.add(id)
138 try {
139 const [cp, cl] = await Promise.all([readAdmin<ChainProposal>('getProposal', [BigInt(id)]), readAdmin<Hex>('clearanceOf', [BigInt(id)])])
140 if (Number(cp.status) !== 0) {
141 applyProposal(id, cp, cl)
142 pickActive()
143 store.emit()
144 }
145 } catch {
146 /* the next event or poll reads it again */
147 } finally {
148 refreshBusy.delete(id)
149 if (refreshAgain.delete(id)) void refreshProposal(id)
150 }
151}
152
153export const liveHooks: LogHooks = {
154 wantReceipt,
155 wantBlockTs,
156 refresh: (id) => void refreshProposal(id),
157 // a live event always wins over playback; playback may start again after CONFIG.replay.idleMs
158 onLive: (ts) => replay.onLive(ts),
159}
160
161// ---------- eth_getLogs cursor ----------
162async function getLogs(from: number, to: number, idTopic?: Hex, pool: Pool = 'head'): Promise<RawLog[]> {
163 const topics: unknown[] = [TOPICS]
164 if (idTopic) topics.push(idTopic)
165 return call<RawLog[]>('eth_getLogs', [{ address: ADDRS, fromBlock: numberToHex(from), toBlock: numberToHex(to), topics }], 4, pool)
166}
167
168/** A past proposal's block span as several getLogs calls of at most CHAIN.logRange blocks, in block order; parallel:
169 * all ranges at once (the first replay's proposal, read alone), else one after another */
170async function getLogsSpan(from: number, to: number, idTopic?: Hex, parallel = false): Promise<RawLog[]> {
171 const ranges: [number, number][] = []
172 for (let a = from; a <= to; a += CHAIN.logRange) ranges.push([a, Math.min(to, a + CHAIN.logRange - 1)])
173 if (parallel) return (await Promise.all(ranges.map(([a, b]) => getLogs(a, b, idTopic, 'history')))).flat()
174 const out: RawLog[] = []
175 for (const [a, b] of ranges) out.push(...(await getLogs(a, b, idTopic, 'history')))
176 return out
177}
178
179let polling = false
180async function pollLogs() {
181 if (polling) return
182 polling = true
183 try {
184 const head = blocks.latest || parseInt(await call<Hex>('eth_blockNumber', []), 16)
185 if (!chainHealth.cursor) chainHealth.cursor = head
186 // at most 5 ranges per round, so a tab left in the background does not hammer the endpoint
187 for (let k = 0; k < 5 && chainHealth.cursor < head; k++) {
188 const from = chainHealth.cursor + 1
189 const to = Math.min(head, from + CHAIN.logRange - 1)
190 for (const l of await getLogs(from, to)) handleLog(l, true, liveHooks)
191 chainHealth.cursor = to
192 }
193 if (chainHealth.logsLink !== 'ws') chainHealth.logsLink = 'poll'
194 } catch {
195 if (chainHealth.logsLink !== 'ws') chainHealth.logsLink = 'down'
196 } finally {
197 polling = false
198 }
199}
200
201// ---------- WS logs subscription ----------
202let wsIdx = 0
203function openLogsWs() {
204 if (CHAIN.ws.length === 0) return
205 const url = CHAIN.ws[wsIdx % CHAIN.ws.length]
206 let sock: WebSocket
207 try {
208 sock = new WebSocket(url)
209 } catch {
210 wsIdx++
211 setTimeout(openLogsWs, 3000)
212 return
213 }
214 sock.onopen = () => sock.send(JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'eth_subscribe', params: ['logs', { address: ADDRS }] }))
215 sock.onmessage = (ev) => {
216 let m: { id?: number; result?: unknown; params?: { result?: RawLog } }
217 try {
218 m = JSON.parse(String(ev.data))
219 } catch {
220 return
221 }
222 if (m.id === 7) {
223 if (m.result) {
224 chainHealth.logsLink = 'ws'
225 store.emit()
226 } else sock.close()
227 return
228 }
229 const l = m.params?.result
230 if (l?.topics) handleLog(l, true, liveHooks)
231 }
232 sock.onclose = () => {
233 if (chainHealth.logsLink === 'ws') chainHealth.logsLink = 'poll'
234 wsIdx++
235 store.emit()
236 setTimeout(openLogsWs, 3000)
237 }
238 sock.onerror = () => {
239 /* onclose follows */
240 }
241}
242
243// ---------- page open: the newest proposals ----------
244/** The order the recent proposals' logs are read in: the first-visit replay's proposal (the autoplay card's newest)
245 * first and alone, so that replay can start as soon as its own history is in; then every other picker card's newest
246 * proposal; then the rest, newest first */
247export function loadOrder(ids: readonly string[], cardOf: (id: string) => LabId | null, first: LabId = AUTOPLAY_CARD): string[] {
248 const byNew = [...ids].sort((a, b) => Number(b) - Number(a))
249 const out: string[] = []
250 const add = (id: string | undefined) => {
251 if (id !== undefined && !out.includes(id)) out.push(id)
252 }
253 add(byNew.find((id) => cardOf(id) === first))
254 for (const c of LAB_IDS) add(byNew.find((id) => cardOf(id) === c))
255 for (const id of byNew) add(id)
256 return out
257}
258
259/** Run fn over items, at most `width` at a time (the public RPCs rate-limit a burst of getLogs calls) */
260async function pool<T>(items: readonly T[], width: number, fn: (x: T) => Promise<void>): Promise<void> {
261 let i = 0
262 const lane = async () => {
263 while (i < items.length) await fn(items[i++])
264 }
265 await Promise.all(Array.from({ length: Math.min(width, items.length) }, lane))
266}
267
268const ord = (l: RawLog) => parseInt(l.blockNumber, 16) * 1e6 + parseInt(l.logIndex, 16)
269
270/** History read on page open: its event-stream lines wait here and go into the stream in chain order once every recent
271 * proposal is in (each proposal's state is applied, and playable, as soon as its own logs arrive) */
272const heldLines: { ord: number; line: StreamLine }[] = []
273function applyHistory(logs: RawLog[]) {
274 for (const l of logs.sort((a, b) => ord(a) - ord(b))) {
275 const last = store.stream[store.stream.length - 1]
276 handleLog(l, false, liveHooks)
277 const from = last ? store.stream.lastIndexOf(last) + 1 : 0
278 for (const line of store.stream.splice(from)) heldLines.push({ ord: ord(l), line })
279 }
280}
281function flushHistory() {
282 heldLines.sort((a, b) => a.ord - b.ord)
283 store.stream.push(...heldLines.map((h) => h.line))
284 heldLines.length = 0
285 while (store.stream.length > 80) store.stream.shift()
286}
287
288async function loadRecent() {
289 // when the page started reading the chain (the intro says so if the first replay's data takes too long)
290 playbackGate.startedAt = Date.now()
291 for (let attempt = 0; ; attempt++) {
292 try {
293 const [nextRaw, headHex] = await Promise.all([readAdmin<bigint>('nextId'), call<Hex>('eth_blockNumber', [])])
294 const next = Number(nextRaw)
295 const head = parseInt(headHex, 16)
296 const ids: number[] = []
297 for (let id = next - 1; id >= 1 && ids.length < CONFIG.recentProposals; id--) ids.push(id)
298 const props = await Promise.all(
299 ids.map(async (id) => {
300 const [cp, cl] = await Promise.all([readAdmin<ChainProposal>('getProposal', [BigInt(id)]), readAdmin<Hex>('clearanceOf', [BigInt(id)])])
301 return [id, cp, cl] as const
302 }),
303 )
304 for (const [id, cp, cl] of props) if (Number(cp.status) !== 0) applyProposal(String(id), cp, cl)
305 pickActive()
306 store.emit()
307 const known = new Map(props.filter(([, cp]) => Number(cp.status) !== 0).map(([id, cp]) => [String(id), cp]))
308 // ?p=<id> older than the recent ones (or not proposed yet): read it first, it is what the viewer asked to see
309 if (PICK && !known.has(PICK) && !playbackGate.loaded.has(PICK)) chainHealth.pickMissing = (await loadPicked(PICK, next, head)) ? null : PICK
310 const cardOf = (id: string) => {
311 const p = store.proposals.get(id)
312 return p ? labIdOf(p, store.implNames) : null
313 }
314 const order = loadOrder([...known.keys()], cardOf).filter((id) => !playbackGate.loaded.has(id))
315 const readOne = async (id: string, parallel = false) => {
316 const cp = known.get(id)!
317 const from = Number(cp.proposedBlock)
318 const to = Math.min(from + CHAIN.historySpan - 1, Math.max(head, blocks.latest))
319 applyHistory(await getLogsSpan(from, to, pad(numberToHex(BigInt(id)), { size: 32 }), parallel))
320 playbackGate.loaded.add(id)
321 store.emit()
322 }
323 // the first one alone (the first-visit replay waits for it), then the rest a few at a time
324 if (order.length) await readOne(order[0], true)
325 await pool(order.slice(1), 3, (id) => readOne(id))
326 flushHistory()
327 chainHealth.loadedRecent = true
328 playbackGate.ready = true
329 store.logT('chain', 'load', msg('ev.loaded', { n: props.length, next }), 'info')
330 store.emit()
331 void backfillLab(next).catch(() => {
332 /* the picker shows "no proposal of this kind yet" for a card it could not find */
333 })
334 return
335 } catch (e) {
336 store.logT('chain', 'load', msg('ev.loadFail', { n: attempt + 1, err: (e as Error).message.slice(0, 80) }), 'warn')
337 store.emit()
338 await new Promise((r) => setTimeout(r, Math.min(10000, 2000 * (attempt + 1))))
339 }
340 }
341}
342
343// ---------- ?p=<id>: a proposal outside the recent ones ----------
344/** Read one proposal the recent window does not hold (?p=<id> of an older one) with its logs, like a recent proposal.
345 * Returns false, reading nothing more, when the chain has no such proposal (yet): id >= nextId, or status None. */
346export async function loadPicked(id: string, next: number, head: number): Promise<boolean> {
347 const n = Number(id)
348 if (!Number.isSafeInteger(n) || n < 1 || n >= next) return false
349 const [cp, cl] = await Promise.all([readAdmin<ChainProposal>('getProposal', [BigInt(n)]), readAdmin<Hex>('clearanceOf', [BigInt(n)])])
350 if (Number(cp.status) === 0) return false
351 applyProposal(id, cp, cl)
352 pickActive()
353 store.emit()
354 const from = Number(cp.proposedBlock)
355 // the head this page knows may lag behind the proposal: read it then
356 if (Math.max(head, blocks.latest) < from) head = parseInt(await call<Hex>('eth_blockNumber', []), 16)
357 const logs = await getLogsSpan(from, Math.min(from + CHAIN.historySpan - 1, Math.max(head, blocks.latest)), pad(numberToHex(BigInt(n)), { size: 32 }), true)
358 for (const l of logs.sort((a, b) => ord(a) - ord(b))) handleLog(l, false, liveHooks)
359 playbackGate.loaded.add(id)
360 store.emit()
361 return true
362}
363
364// ---------- attack picker: older proposals of a card missing from the recent ones ----------
365const labMissing = () => LAB_IDS.filter((id) => !labSatisfied(id, pickFor(id, store.proposals.values(), store.implNames)))
366
367/** Read older proposal ids (newest first, ids already loaded skipped) until every picker card has a judged proposal,
368 * at most `max` ids; each one found gets its logs read from its proposal block, like a recent proposal */
369export async function backfillLab(next: number, max = CONFIG.labScanMax): Promise<string[]> {
370 const found: string[] = []
371 let want = labMissing()
372 let head = Math.max(blocks.latest, 0)
373 for (let id = next - 1, n = 0; id >= 1 && n < max && want.length; id--) {
374 const key = String(id)
375 if (store.proposals.has(key)) continue
376 n++
377 const cp = await readAdmin<ChainProposal>('getProposal', [BigInt(id)])
378 if (Number(cp.status) === 0) continue
379 const lid = labIdOf({ ...newProposal(key), kind: String(cp.kind), target: cp.target, value: String(cp.value) }, store.implNames)
380 if (!lid || !want.includes(lid)) continue
381 const cl = await readAdmin<Hex>('clearanceOf', [BigInt(id)])
382 applyProposal(key, cp, cl)
383 const from = Number(cp.proposedBlock)
384 // the head this page knows may lag behind the proposal: read it then
385 if (Math.max(head, blocks.latest) < from) head = parseInt(await call<Hex>('eth_blockNumber', []), 16)
386 const logs = await getLogsSpan(from, Math.min(from + CHAIN.historySpan - 1, Math.max(head, blocks.latest)), pad(numberToHex(BigInt(id)), { size: 32 }))
387 for (const l of logs.sort((a, b) => ord(a) - ord(b))) handleLog(l, false, liveHooks)
388 playbackGate.loaded.add(key)
389 found.push(key)
390 want = labMissing()
391 }
392 if (found.length) store.emit()
393 return found
394}
395
396export function startChainWatch() {
397 store.deploy = { airlockAdmin: DATA.airlockAdmin, proxy: DATA.proxy, delayBlocks: DATA.delayBlocks, chainId: DATA.chainId }
398 loadImplNames()
399 // open the history endpoint's connection (TLS, CORS preflight) while the proposals are read from the head endpoint,
400 // so the first replay's logs do not wait for it
401 const h = CHAIN.historyHttp[0]
402 if (h && h !== CHAIN.http[0]) void rpc(h, 'eth_chainId', [], 8000).catch(() => {})
403 void loadRecent()
404 openLogsWs()
405 // cursor: every 4 s while the WS is up, every second while it is down
406 let tick = 0
407 setInterval(() => {
408 tick++
409 if (chainHealth.logsLink !== 'ws' || tick % 4 === 0) void pollLogs()
410 // a pending proposal whose cooldown just ended: read it again (it may be waiting for its clearance)
411 if (tick % 12 === 0)
412 for (const p of store.proposals.values()) if (p.chain?.status === 1 && p.readyBlock && blocks.latest >= p.readyBlock) void refreshProposal(p.id)
413 }, 1000)
414}