web/src/events.ts234 lines · 9773 bytes
| 1 | // One log from AirlockAdmin or AirlockReceiver -> proposal state + one event-stream line. |
| 2 | // Shared by the live reader (chainwatch.ts) and offline playback (offline.ts); network work is done through hooks. |
| 3 | import { decodeEventLog, type Hex } from 'viem' |
| 4 | import { ACTION_CLEAR, ACTION_VETO, ADMIN_ABI, RECEIVER_ABI } from './abi' |
| 5 | import { DATA, type RawLog } from './chaindata' |
| 6 | import { msg, type Msg } from './i18n' |
| 7 | import type { GuardId } from './runtime' |
| 8 | import { short, store, type Clearance, type Proposal } from './store' |
| 9 | import { IMPL_KEYS, RULE_BY_HASH, type CardId } from './types' |
| 10 | |
| 11 | export interface LogHooks { |
| 12 | /** read this transaction's receipt (gas cost) */ |
| 13 | wantReceipt(tx: string): void |
| 14 | /** read this block's timestamp */ |
| 15 | wantBlockTs(n: number): void |
| 16 | /** the proposal changed on chain: read getProposal + clearanceOf again */ |
| 17 | refresh(id: string): void |
| 18 | /** a live (not historical) event arrived */ |
| 19 | onLive(ts: number): void |
| 20 | } |
| 21 | |
| 22 | /** block number -> block timestamp (s); filled by the live reader or from the recording */ |
| 23 | export const blockTs = new Map<number, number>() |
| 24 | |
| 25 | const lc = (a: unknown) => String(a ?? '').toLowerCase() |
| 26 | const RX = lc(DATA.receiver) |
| 27 | const ADMIN = lc(DATA.airlockAdmin) |
| 28 | |
| 29 | /** guardian address -> guard card */ |
| 30 | export function guardOf(guardian: string): GuardId | null { |
| 31 | const g = lc(guardian) |
| 32 | if (g === RX) return 'cre' |
| 33 | if (g === lc(DATA.emergencyGuardian)) return 'breakglass' |
| 34 | return null |
| 35 | } |
| 36 | |
| 37 | /** clearer address -> who cleared */ |
| 38 | export function clearerOf(addr: string): Clearance['by'] { |
| 39 | const a = lc(addr) |
| 40 | return a === RX ? 'cre' : a === lc(DATA.approver) ? 'approver' : 'other' |
| 41 | } |
| 42 | |
| 43 | /** Stream name of a guard or clearer */ |
| 44 | export function whoMsg(by: string, addr = ''): Msg { |
| 45 | if (by === 'cre') return msg('who.cre') |
| 46 | if (by === 'breakglass') return msg('who.human') |
| 47 | if (by === 'approver') return msg('who.approver') |
| 48 | return msg('who.unknown', { a: short(addr, 4) }) |
| 49 | } |
| 50 | |
| 51 | const ruleName = (ruleHash: string) => RULE_BY_HASH.get(lc(ruleHash)) ?? null |
| 52 | |
| 53 | function recognise(p: Proposal) { |
| 54 | const name = store.implNames.get(lc(p.target)) |
| 55 | if (name && name !== 'v1' && name !== 'paused') p.card = name as CardId |
| 56 | } |
| 57 | |
| 58 | const seen = new Set<string>() |
| 59 | /** Forget everything (offline playback restarts from a clean store) */ |
| 60 | export function resetSeen() { |
| 61 | seen.clear() |
| 62 | } |
| 63 | |
| 64 | /** Apply one log. Returns the proposal id it touched, or null when the log was ignored. */ |
| 65 | export function handleLog(l: RawLog, live: boolean, hooks: LogHooks, ts = Date.now()): string | null { |
| 66 | const addr = lc(l?.address) |
| 67 | const fromRx = addr === RX |
| 68 | if (!l?.topics?.length || (addr !== ADMIN && !fromRx)) return null |
| 69 | const key = `${lc(l.transactionHash)}|${lc(l.logIndex)}` |
| 70 | if (l.removed) { |
| 71 | if (seen.delete(key)) { |
| 72 | const id = l.topics[1] ? String(BigInt(l.topics[1])) : '' |
| 73 | const p = id ? store.proposals.get(id) : undefined |
| 74 | if (p) { |
| 75 | p.dropped = true |
| 76 | hooks.refresh(id) |
| 77 | } |
| 78 | store.logT('chain', 'removed', msg('ev.removed', { tx: short(l.transactionHash) }), 'warn', ts) |
| 79 | store.emit() |
| 80 | } |
| 81 | return null |
| 82 | } |
| 83 | if (seen.has(key)) return null |
| 84 | let ev: { eventName: string; args: Record<string, unknown> } |
| 85 | try { |
| 86 | ev = decodeEventLog({ abi: fromRx ? RECEIVER_ABI : ADMIN_ABI, data: l.data, topics: l.topics as [Hex, ...Hex[]] }) as never |
| 87 | } catch { |
| 88 | return null |
| 89 | } |
| 90 | seen.add(key) |
| 91 | if (live) hooks.onLive(ts) |
| 92 | const block = parseInt(l.blockNumber, 16) |
| 93 | const tx = l.transactionHash |
| 94 | // chain order of this log: the first veto, verdict and reason win over later copies (see the Vetoed case) |
| 95 | const order = block * 1e6 + parseInt(l.logIndex, 16) |
| 96 | const id = String(ev.args.proposalId ?? ev.args.id) |
| 97 | const p = store.ensure(id) |
| 98 | const after = p.proposeBlock ? msg('ev.after', { n: block - p.proposeBlock }) : '' |
| 99 | hooks.wantBlockTs(block) |
| 100 | |
| 101 | switch (ev.eventName) { |
| 102 | case 'Proposed': { |
| 103 | p.kind = String(ev.args.kind) |
| 104 | p.target = String(ev.args.target) |
| 105 | p.value = String(ev.args.value) |
| 106 | p.codehash = String(ev.args.codehash) |
| 107 | p.proposeBlock = block |
| 108 | p.readyBlock = Number(ev.args.readyBlock) |
| 109 | p.proposeTx = tx |
| 110 | p.dropped = false |
| 111 | recognise(p) |
| 112 | if (live) { |
| 113 | store.arrival = { id, at: ts } |
| 114 | // auto-follow: the new proposal takes over the screen (F turns it off) |
| 115 | if (store.follow) { |
| 116 | manualPick = false |
| 117 | store.activeId = id |
| 118 | } |
| 119 | } |
| 120 | store.logT('chain', 'Proposed', msg('ev.proposed', { id, b: block, rb: p.readyBlock, tx: short(tx) }), 'info', ts) |
| 121 | break |
| 122 | } |
| 123 | case 'Vetoed': { |
| 124 | const guardian = String(ev.args.guardian) |
| 125 | const g = guardOf(guardian) |
| 126 | const rule = ruleName(String(ev.args.ruleHash)) |
| 127 | if (g) { |
| 128 | const a = p.guards[g] |
| 129 | a.verdict = 'veto' |
| 130 | // the first veto stays on screen. Anyone can re-send a mined veto report until it expires (on Sepolia the |
| 131 | // forwarder is Chainlink's permissionless test forwarder), and AirlockAdmin then emits Vetoed again; such a |
| 132 | // replay leaves the proposal's state as it was, so its block and tx must not replace the first veto's ("vetoed |
| 133 | // N blocks after the proposal"). The event stream still lists it: it is a real chain event. |
| 134 | if (a.vetoOrder === undefined || order < a.vetoOrder) { |
| 135 | a.rule = rule ?? a.rule ?? null |
| 136 | a.ruleHash = String(ev.args.ruleHash) |
| 137 | a.tx = tx |
| 138 | a.block = block |
| 139 | a.vetoOrder = order |
| 140 | hooks.wantReceipt(tx) |
| 141 | } |
| 142 | } |
| 143 | const who = whoMsg(g ?? 'other', guardian) |
| 144 | store.logT('chain', 'Vetoed', msg('ev.vetoed', { id, who, b: block, after, rule: rule ?? short(String(ev.args.ruleHash), 4), n: String(ev.args.vetoCount) }), 'bad', ts) |
| 145 | break |
| 146 | } |
| 147 | case 'Cleared': { |
| 148 | const clearer = String(ev.args.clearer) |
| 149 | const by = clearerOf(clearer) |
| 150 | const order = block * 1e6 + parseInt(l.logIndex, 16) |
| 151 | // AirlockAdmin keeps the first evidence hash; a later Cleared only repeats the call |
| 152 | if (!p.clearance || (p.clearance.order ?? Infinity) > order) { |
| 153 | p.clearance = { evidenceHash: String(ev.args.evidenceHash), clearer, by, tx, block, order } |
| 154 | if (by === 'cre') { |
| 155 | const a = p.guards.cre |
| 156 | a.verdict = 'pass' |
| 157 | a.tx = tx |
| 158 | a.block = block |
| 159 | a.evidenceHash = a.evidenceHash ?? String(ev.args.evidenceHash) |
| 160 | } |
| 161 | hooks.wantReceipt(tx) |
| 162 | } |
| 163 | store.logT('chain', 'Cleared', msg('ev.cleared', { id, who: whoMsg(by, clearer), b: block, after, h: short(String(ev.args.evidenceHash)) }), 'good', ts) |
| 164 | break |
| 165 | } |
| 166 | case 'Executed': |
| 167 | p.execute = { tx, block } |
| 168 | hooks.wantReceipt(tx) |
| 169 | store.logT('chain', 'Executed', msg('ev.executed', { id, b: block, tx: short(tx) }), 'good', ts) |
| 170 | break |
| 171 | case 'ReasonAttached': { |
| 172 | // the first one stays (a re-sent veto report attaches its reason again) |
| 173 | if (!p.reason || (p.reason.order ?? Infinity) > order) { |
| 174 | p.reason = { tx, hash: String(ev.args.reportHash), block, order } |
| 175 | if (guardOf(String(ev.args.guardian)) === 'cre') p.guards.cre.evidenceHash = String(ev.args.reportHash) |
| 176 | } |
| 177 | store.logT('chain', 'ReasonAttached', msg('ev.reason', { id, h: short(String(ev.args.reportHash)) }), 'info', ts) |
| 178 | break |
| 179 | } |
| 180 | case 'Verdict': { |
| 181 | const action = Number(ev.args.action) |
| 182 | const a = p.guards.cre |
| 183 | // the first Verdict's workflowId and evidence hash stay: a re-sent report carries metadata the sender chose |
| 184 | if (a.verdictOrder === undefined || order < a.verdictOrder) { |
| 185 | a.evidenceHash = String(ev.args.evidenceHash) |
| 186 | a.workflowId = String(ev.args.workflowId) |
| 187 | a.verdictOrder = order |
| 188 | } |
| 189 | const rule = ruleName(String(ev.args.ruleHash)) |
| 190 | if (action === ACTION_VETO) { |
| 191 | a.verdict = 'veto' |
| 192 | a.rule = rule ?? a.rule ?? null |
| 193 | a.ruleHash = String(ev.args.ruleHash) |
| 194 | } else if (action === ACTION_CLEAR && a.verdict !== 'veto') a.verdict = 'pass' |
| 195 | a.tx = a.tx ?? tx |
| 196 | a.block = a.block ?? block |
| 197 | const what = action === ACTION_CLEAR ? msg('ev.vClear') : msg('ev.vVeto', { rule: rule ?? short(String(ev.args.ruleHash), 4) }) |
| 198 | // the stream line is this log as it is on chain (a re-sent report shows its own metadata here, never on the cards) |
| 199 | store.logT('chain', 'Verdict', msg('ev.verdict', { id, what, h: short(String(ev.args.evidenceHash)), w: short(String(ev.args.workflowId), 4) }), action === ACTION_CLEAR ? 'good' : 'bad', ts) |
| 200 | break |
| 201 | } |
| 202 | } |
| 203 | // a live event about the newest proposal (its verdict, its execution) stopped any replay on screen: with auto-follow |
| 204 | // on, that proposal takes the screen, as its arrival did, instead of the replayed one's final state |
| 205 | if (live && store.follow && ev.eventName !== 'Proposed' && id === store.newestId() && store.activeId !== id) { |
| 206 | manualPick = false |
| 207 | store.activeId = id |
| 208 | } |
| 209 | pickActive() |
| 210 | store.emit() |
| 211 | hooks.refresh(id) |
| 212 | return id |
| 213 | } |
| 214 | |
| 215 | /** The viewer picked a proposal: stay on it (until a new proposal arrives, when auto-follow is on) */ |
| 216 | let manualPick = false |
| 217 | export function setManualPick(on: boolean) { |
| 218 | manualPick = on |
| 219 | } |
| 220 | |
| 221 | /** Follow the newest proposal unless the viewer picked one */ |
| 222 | export function pickActive() { |
| 223 | let best: Proposal | null = null |
| 224 | for (const p of store.proposals.values()) if (!best || Number(p.id) > Number(best.id)) best = p |
| 225 | if (best && store.activeId !== best.id && !manualPick) store.activeId = best.id |
| 226 | } |
| 227 | |
| 228 | /** Implementation addresses of the deployment -> demo names (EvilMint, DemoBTC v2, ...) */ |
| 229 | export function loadImplNames() { |
| 230 | for (const k of IMPL_KEYS) { |
| 231 | const a = DATA.impls[k] |
| 232 | if (a) store.implNames.set(a.toLowerCase(), k) |
| 233 | } |
| 234 | } |