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 / airlock.ts
web/src/airlock.ts175 lines · 6690 bytes
1// The airlock picture of one proposal:
2// outer door = Proposed (the upgrade enters the chamber)
3// chamber = the cooldown (readyBlock - proposeBlock blocks, shown in seconds at 12 s/block)
4// inner door = stays locked until the Chainlink CRE clearance lands AND the cooldown is over; then Executed
5// ejected = Vetoed (by the CRE through AirlockReceiver, or by the human emergency guardian); a veto beats a clearance
6// airlockView() is pure (no DOM, no store; the time is passed in), so the tests call it directly.
7import { CONFIG } from './config'
8import { onLangChange, t } from './i18n'
9import { GUARDS, type GuardId } from './runtime'
10import type { Clearance, Proposal } from './store'
11
12/** idle: no proposal; chamber: cooling, not cleared; cleared: cooling, clearance landed; waiting: cooldown over but no
13 * clearance (execute reverts NotCleared); ready: cooldown over and cleared (executable); executed; ejected */
14export type AirlockPhase = 'idle' | 'chamber' | 'cleared' | 'waiting' | 'ready' | 'executed' | 'ejected'
15
16interface Timing {
17 block: number
18 tx: string | undefined
19 /** proposal block -> this block */
20 afterBlocks: number
21 /** seconds after the proposal: block timestamps when known, otherwise blocks x blockSec */
22 afterSec: number
23 secFromTs: boolean
24}
25
26export interface AirlockVeto extends Timing {
27 by: GuardId
28 rule: string | null
29 ruleHash: string | null
30 evidenceHash: string | undefined
31 workflowId: string | undefined
32 /** veto block -> readyBlock (how far the attacker still was from the inner door) */
33 toInner: number
34}
35
36export interface AirlockClear extends Timing {
37 by: Clearance['by']
38 clearer: string
39 evidenceHash: string
40 workflowId: string | undefined
41 /** clearance block -> readyBlock; negative when the clearance landed after the cooldown ended */
42 beforeReady: number
43}
44
45export interface AirlockView {
46 phase: AirlockPhase
47 /** cooldown length in blocks */
48 total: number
49 /** blocks left to readyBlock, 0..total */
50 left: number
51 leftSec: number
52 /** blocks past readyBlock (waiting / ready) */
53 over: number
54 /** cooldown progress 0-100 (stops at the veto block when ejected) */
55 pct: number
56 /** first veto that landed; null when the chain says Vetoed but the veto log was out of reach */
57 veto: AirlockVeto | null
58 /** the clearance (first Cleared, or clearanceOf when the log was out of reach: then no block/tx) */
59 clear: AirlockClear | null
60 /** clearanceOf / Cleared says cleared even if no log was read */
61 cleared: boolean
62}
63
64function timing(block: number, tx: string | undefined, pb: number, ts: (n: number) => number | undefined, blockSec: number): Timing {
65 const t0 = ts(pb)
66 const t1 = ts(block)
67 const secFromTs = t0 !== undefined && t1 !== undefined && t1 >= t0
68 return { block, tx, afterBlocks: block - pb, afterSec: secFromTs ? t1! - t0! : (block - pb) * blockSec, secFromTs }
69}
70
71/** First veto (lowest block; on the same block the CRE card first, as guardians are [CRE, human]) */
72function firstVeto(p: Proposal): { by: GuardId; block: number } | null {
73 let best: { by: GuardId; block: number } | null = null
74 for (const g of GUARDS) {
75 const a = p.guards[g]
76 if (a?.verdict === 'veto' && typeof a.block === 'number' && a.block > 0 && (!best || a.block < best.block)) best = { by: g, block: a.block }
77 }
78 return best
79}
80
81/**
82 * @param cur current block (the virtual block during playback)
83 * @param ts block number -> block timestamp (s), or undefined
84 */
85export function airlockView(
86 p: Proposal | null,
87 cur: number,
88 ts: (n: number) => number | undefined = () => undefined,
89 blockSec: number = CONFIG.blockSec,
90): AirlockView {
91 if (!p || !p.proposeBlock || !p.readyBlock) {
92 const total = p?.readyBlock && p.proposeBlock ? p.readyBlock - p.proposeBlock : 0
93 return { phase: 'idle', total, left: total, leftSec: total * blockSec, over: 0, pct: 0, veto: null, clear: null, cleared: false }
94 }
95 const pb = p.proposeBlock
96 const rb = p.readyBlock
97 const total = Math.max(1, rb - pb)
98 // playback lead-in: the virtual clock starts a couple of blocks before the proposal
99 if (cur < pb) return { phase: 'idle', total, left: total, leftSec: total * blockSec, over: 0, pct: 0, veto: null, clear: null, cleared: false }
100 const left = Math.min(total, Math.max(0, rb - cur))
101 const over = Math.max(0, cur - rb)
102
103 let veto: AirlockVeto | null = null
104 const fv = firstVeto(p)
105 if (fv) {
106 const a = p.guards[fv.by]
107 veto = {
108 ...timing(fv.block, a.tx, pb, ts, blockSec),
109 by: fv.by,
110 rule: a.rule ?? null,
111 ruleHash: a.ruleHash ?? null,
112 evidenceHash: fv.by === 'cre' ? a.evidenceHash : undefined,
113 workflowId: fv.by === 'cre' ? a.workflowId : undefined,
114 toInner: Math.max(0, rb - fv.block),
115 }
116 }
117
118 let clear: AirlockClear | null = null
119 const c = p.clearance
120 if (c?.block) {
121 clear = {
122 ...timing(c.block, c.tx, pb, ts, blockSec),
123 by: c.by,
124 clearer: c.clearer,
125 // AirlockAdmin keeps the first hash: clearanceOf wins over a log
126 evidenceHash: p.chain?.clearanceOf ?? c.evidenceHash,
127 workflowId: c.by === 'cre' ? p.guards.cre.workflowId : undefined,
128 beforeReady: rb - c.block,
129 }
130 }
131 const cleared = !!clear || !!p.chain?.clearanceOf
132
133 const vetoed = !!veto || p.chain?.status === 2
134 const executed = !vetoed && (p.chain?.status === 3 || !!p.execute)
135 const phase: AirlockPhase = vetoed
136 ? 'ejected'
137 : executed
138 ? 'executed'
139 : cur >= rb
140 ? cleared
141 ? 'ready'
142 : 'waiting'
143 : cleared
144 ? 'cleared'
145 : 'chamber'
146 const doneBlocks = veto ? Math.min(total, veto.afterBlocks) : phase === 'executed' || phase === 'ready' || phase === 'waiting' ? total : total - left
147 return { phase, total, left, leftSec: left * blockSec, over, pct: Math.min(100, Math.round((doneBlocks / total) * 100)), veto, clear, cleared }
148}
149
150/** Seconds -> "24 s", "4 min 36 s" (current language) */
151export function fmtDur(sec: number): string {
152 const s = Math.max(0, Math.round(sec))
153 if (s < 60) return t('dur.sec', { s })
154 if (s % 60 === 0) return t('dur.minOnly', { m: s / 60 })
155 return t('dur.min', { m: Math.floor(s / 60), s: s % 60 })
156}
157
158/** Seconds -> "4:36" (a countdown clock; the same in every language) */
159export function fmtClock(sec: number): string {
160 const s = Math.max(0, Math.ceil(sec))
161 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
162}
163
164/** Tab title follows the language */
165export function startTitle() {
166 const title = () => {
167 try {
168 document.title = t('doc.title')
169 } catch {
170 /* Node tests */
171 }
172 }
173 title()
174 onLangChange(title)
175}