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 / events.ts
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.
3import { decodeEventLog, type Hex } from 'viem'
4import { ACTION_CLEAR, ACTION_VETO, ADMIN_ABI, RECEIVER_ABI } from './abi'
5import { DATA, type RawLog } from './chaindata'
6import { msg, type Msg } from './i18n'
7import type { GuardId } from './runtime'
8import { short, store, type Clearance, type Proposal } from './store'
9import { IMPL_KEYS, RULE_BY_HASH, type CardId } from './types'
10
11export 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 */
23export const blockTs = new Map<number, number>()
24
25const lc = (a: unknown) => String(a ?? '').toLowerCase()
26const RX = lc(DATA.receiver)
27const ADMIN = lc(DATA.airlockAdmin)
28
29/** guardian address -> guard card */
30export 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 */
38export 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 */
44export 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
51const ruleName = (ruleHash: string) => RULE_BY_HASH.get(lc(ruleHash)) ?? null
52
53function 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
58const seen = new Set<string>()
59/** Forget everything (offline playback restarts from a clean store) */
60export function resetSeen() {
61 seen.clear()
62}
63
64/** Apply one log. Returns the proposal id it touched, or null when the log was ignored. */
65export 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) */
216let manualPick = false
217export function setManualPick(on: boolean) {
218 manualPick = on
219}
220
221/** Follow the newest proposal unless the viewer picked one */
222export 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, ...) */
229export 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}