web/src/store.ts157 lines · 5738 bytes
| 1 | // Front-end state: proposals keyed by id, built from AirlockAdmin and AirlockReceiver logs (live or recorded). |
| 2 | import { CONFIG } from './config' |
| 3 | import { tIn, type Msg } from './i18n' |
| 4 | import { GUARDS, type GuardId } from './runtime' |
| 5 | import type { CardId, ImplName } from './types' |
| 6 | |
| 7 | /** Receipt numbers of a verdict transaction (veto or clearance), read with eth_getTransactionReceipt */ |
| 8 | export interface TxCost { |
| 9 | status?: 'success' | 'reverted' |
| 10 | gasUsed?: string |
| 11 | effectiveGasPrice?: string |
| 12 | } |
| 13 | |
| 14 | /** What one guard did on one proposal */ |
| 15 | export 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) */ |
| 34 | export 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 | |
| 45 | export 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 | |
| 67 | export 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) */ |
| 81 | export 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) */ |
| 89 | export type VerifyState = 'pending' | 'yes' | 'no' | 'err' |
| 90 | |
| 91 | export 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 | |
| 97 | export 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 | |
| 150 | export 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 | |
| 157 | export const store = new Store() |