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 / store.ts
web/src/store.ts157 lines · 5738 bytes
1// Front-end state: proposals keyed by id, built from AirlockAdmin and AirlockReceiver logs (live or recorded).
2import { CONFIG } from './config'
3import { tIn, type Msg } from './i18n'
4import { GUARDS, type GuardId } from './runtime'
5import type { CardId, ImplName } from './types'
6
7/** Receipt numbers of a verdict transaction (veto or clearance), read with eth_getTransactionReceipt */
8export interface TxCost {
9 status?: 'success' | 'reverted'
10 gasUsed?: string
11 effectiveGasPrice?: string
12}
13
14/** What one guard did on one proposal */
15export interface GuardAction {
16 /** veto: Vetoed by this guardian; pass: the CRE cleared it (only the CRE card can pass) */
17 verdict?: 'pass' | 'veto'
18 rule?: string | null
19 ruleHash?: string | null
20 tx?: string
21 block?: number
22 /** CRE only: keccak256 of the canonical evidence JSON (Verdict / Cleared / ReasonAttached) */
23 evidenceHash?: string
24 /** CRE only: workflowId from the report metadata (Verdict) */
25 workflowId?: string
26 /** Order (block * 1e6 + logIndex) of the Vetoed log whose block and tx are kept: the first one. A mined veto report
27 * can be re-sent by anyone while it is valid, and AirlockAdmin emits Vetoed again; that replay never replaces it */
28 vetoOrder?: number
29 /** Order of the Verdict log whose workflowId and evidence hash are kept (the first one, for the same reason) */
30 verdictOrder?: number
31}
32
33/** The first accepted clearance (AirlockAdmin keeps the first evidence hash) */
34export interface Clearance {
35 evidenceHash: string
36 clearer: string
37 /** cre: the AirlockReceiver (a Chainlink CRE report); approver: cleared by hand; other: any other caller */
38 by: 'cre' | 'approver' | 'other'
39 tx?: string
40 block?: number
41 /** Order of the Cleared log (block * 1e6 + logIndex): an earlier log replaces a later one */
42 order?: number
43}
44
45export interface Proposal {
46 id: string
47 kind: string
48 target: string
49 /** Proposed.value: the enabled flag of a guardian change (1 add, 0 remove), the new delay of a cooldown change */
50 value?: string
51 codehash: string
52 proposeBlock: number | null
53 readyBlock: number | null
54 proposeTx?: string
55 card?: CardId
56 guards: Record<GuardId, GuardAction>
57 clearance?: Clearance
58 execute?: { tx: string; block: number }
59 /** ReasonAttached: the evidence hash written next to a veto (the first one; order = block * 1e6 + logIndex) */
60 reason?: { tx: string; hash: string; block: number; order?: number }
61 /** getProposal + clearanceOf as read from the chain (status 0 None, 1 Pending, 2 Vetoed, 3 Executed) */
62 chain?: { status: number; vetoCount: number; proposer: string; clearanceOf: string | null }
63 /** The proposal's log disappeared in a reorg */
64 dropped?: boolean
65}
66
67export interface StreamLine {
68 ts: number
69 source: string
70 type: string
71 /** zh-CN text (tests and logs) */
72 text: string
73 /** Template key + params: re-rendered in the current language */
74 msg: Msg
75 level: 'info' | 'good' | 'bad' | 'warn'
76 /** Written by the playback of a recorded proposal */
77 replay?: boolean
78}
79
80/** Deployment addresses in use (live: src/chain-data-sepolia.json; offline: the recorded run) */
81export interface DeployInfo {
82 airlockAdmin: string
83 proxy: string
84 delayBlocks: number
85 chainId: number
86}
87
88/** Sourcify lookup: yes = exact/partial match; no = known but unverified; err = network failure (no claim made) */
89export type VerifyState = 'pending' | 'yes' | 'no' | 'err'
90
91export function newProposal(id: string): Proposal {
92 const guards = {} as Record<GuardId, GuardAction>
93 for (const g of GUARDS) guards[g] = {}
94 return { id, kind: '0', target: '', codehash: '', proposeBlock: null, readyBlock: null, guards }
95}
96
97export class Store {
98 proposals = new Map<string, Proposal>()
99 activeId: string | null = null
100 stream: StreamLine[] = []
101 /** implementation address (lowercase) -> demo name */
102 implNames = new Map<string, ImplName>()
103 deploy: DeployInfo | null = null
104 verified = new Map<string, VerifyState>()
105 /** tx hash (lowercase) -> receipt numbers of verdict and execute transactions */
106 receipts = new Map<string, TxCost>()
107 /** Auto-follow (F): a live proposal takes over the screen as it arrives */
108 follow = true
109 /** The last proposal that arrived live (not from history): the screen plays its arrival */
110 arrival: { id: string; at: number } | null = null
111 private listeners = new Set<() => void>()
112
113 onChange(fn: () => void) {
114 this.listeners.add(fn)
115 }
116 emit() {
117 for (const fn of this.listeners) fn()
118 }
119
120 get active(): Proposal | null {
121 return this.activeId ? (this.proposals.get(this.activeId) ?? null) : null
122 }
123
124 ensure(id: string): Proposal {
125 let p = this.proposals.get(id)
126 if (!p) {
127 p = newProposal(id)
128 this.proposals.set(id, p)
129 }
130 return p
131 }
132
133 /** Newest first, at most CONFIG.recentProposals */
134 recentIds(): string[] {
135 return [...this.proposals.keys()].sort((a, b) => Number(b) - Number(a)).slice(0, CONFIG.recentProposals)
136 }
137
138 /** Highest proposal id, or null */
139 newestId(): string | null {
140 return this.recentIds()[0] ?? null
141 }
142
143 /** One line in the event stream: stores the template (re-rendered on a language switch) and the zh-CN text */
144 logT(source: string, type: string, m: Msg, level: StreamLine['level'] = 'info', ts = Date.now(), replay = false) {
145 this.stream.push({ ts, source, type, text: tIn('zh-CN', m.k, m.p), msg: m, level, ...(replay ? { replay } : {}) })
146 if (this.stream.length > 80) this.stream.shift()
147 }
148}
149
150export function short(h: unknown, n = 6): string {
151 const s = typeof h === 'string' ? h : ''
152 if (!s) return '—'
153 if (s.length <= 2 + n * 2) return s
154 return `${s.slice(0, 2 + n)}…${s.slice(-4)}`
155}
156
157export const store = new Store()