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. |
| 7 | import { CONFIG } from './config' |
| 8 | import { onLangChange, t } from './i18n' |
| 9 | import { GUARDS, type GuardId } from './runtime' |
| 10 | import 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 */ |
| 14 | export type AirlockPhase = 'idle' | 'chamber' | 'cleared' | 'waiting' | 'ready' | 'executed' | 'ejected' |
| 15 | |
| 16 | interface 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 | |
| 26 | export 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 | |
| 36 | export 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 | |
| 45 | export 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 | |
| 64 | function 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]) */ |
| 72 | function 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 | */ |
| 85 | export 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) */ |
| 151 | export 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) */ |
| 159 | export 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 */ |
| 165 | export 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 | } |