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. |
| 11 | import { decodeFunctionResult, encodeFunctionData, numberToHex, pad, toEventSelector, type Hex } from 'viem' |
| 12 | import { ADMIN_ABI, RECEIVER_ABI } from './abi' |
| 13 | import { blocks, rpc } from './chain' |
| 14 | import { DATA, type RawLog } from './chaindata' |
| 15 | import { CONFIG } from './config' |
| 16 | import { blockTs, handleLog, loadImplNames, pickActive, type LogHooks } from './events' |
| 17 | import { msg } from './i18n' |
| 18 | import { playbackGate, replay } from './playback' |
| 19 | import { CHAIN, PICK } from './runtime' |
| 20 | import { newProposal, store, type StreamLine } from './store' |
| 21 | import { isZero32 } from './types' |
| 22 | import { AUTOPLAY_CARD, LAB_IDS, labIdOf, labSatisfied, pickFor, type LabId } from './lab' |
| 23 | |
| 24 | const ADMIN = DATA.airlockAdmin as Hex |
| 25 | const RX = DATA.receiver as Hex |
| 26 | const ADDRS: Hex[] = [ADMIN, RX] |
| 27 | const ADMIN_EVENTS = ['Proposed', 'Vetoed', 'Cleared', 'Executed', 'ReasonAttached'] as const |
| 28 | const 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) */ |
| 31 | export 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" */ |
| 35 | export 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) |
| 40 | const rot = { head: 0, history: 0 } |
| 41 | type Pool = keyof typeof rot |
| 42 | const poolUrls = (pool: Pool) => (pool === 'history' && CHAIN.historyHttp.length ? CHAIN.historyHttp : CHAIN.http) |
| 43 | async 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 | |
| 60 | async 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 | |
| 66 | interface 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) ---------- |
| 79 | const tsBusy = new Set<number>() |
| 80 | function 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 | |
| 96 | const receiptBusy = new Set<string>() |
| 97 | function 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 ---------- |
| 116 | function 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 | |
| 130 | const refreshBusy = new Set<string>() |
| 131 | const refreshAgain = new Set<string>() |
| 132 | async 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 | |
| 153 | export 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 ---------- |
| 162 | async 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 */ |
| 170 | async 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 | |
| 179 | let polling = false |
| 180 | async 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 ---------- |
| 202 | let wsIdx = 0 |
| 203 | function 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 */ |
| 247 | export 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) */ |
| 260 | async 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 | |
| 268 | const 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) */ |
| 272 | const heldLines: { ord: number; line: StreamLine }[] = [] |
| 273 | function 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 | } |
| 281 | function 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 | |
| 288 | async 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. */ |
| 346 | export 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 ---------- |
| 365 | const 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 */ |
| 369 | export 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 | |
| 396 | export 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 | } |