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 / cre / veto / airlock.ts
cre/veto/airlock.ts389 lines · 18068 bytes
1// What the workflow does with a proposal, independent of the CRE SDK: the SDK calls are behind `Ports`, so the
2// same code runs in the DON (workflow.ts wires the EVM, HTTP and secrets capabilities) and in Bun unit tests
3// (an in-memory chain).
4//
5// Both handlers end in the same place: every judged proposal gets an on-chain report.
6// pass -> Clear (AirlockAdmin.clear: the proposal may execute after its cooldown)
7// veto -> Veto (AirlockAdmin.veto + attachReason: it never executes)
8// A proposal nobody has judged has no clearance and cannot execute, so a missed log or a failed run delays an
9// upgrade instead of letting it through. The sweeper (cron) catches up on those.
10//
11// "Judged" means judged by this workflow: AirlockAdmin.oracleJudged(id), set when the receiver (the clearance
12// oracle) clears or vetoes. A manual clearance by the approver does not set it, so the workflow still judges a
13// proposal the approver cleared, and its Veto still wins over that clearance.
14import { type Address, decodeFunctionResult, encodeFunctionData, type Hex } from 'viem'
15import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, STATUS, TOKEN_ABI, ZERO_HASH } from './abi'
16import { BudgetError, type Read, readCost } from './chain'
17import type { Config } from './config'
18import { consensusValue, judgeConfig, staticUpgradeCheck, type UpgradeContext, type Verdict } from './judge'
19import { actionHash, upgradeDataKey } from './keys'
20import { actionOf, buildSignedReport, canonicalEvidence, evidenceHashOf, type ProposalFields, proposalDigest } from './report'
21import type { RuleId } from './rules'
22import { proposalActors } from './simulate'
23
24export type Proposal = ProposalFields & { status: number; vetoCount: bigint; clearance: Hex; oracleJudged: boolean }
25
26export type WriteResult = { ok: boolean; tx?: Hex; error?: string }
27
28export interface Chain {
29 /** ChainRead calls still available in this run. */
30 readsLeft(): number
31 /** Latest block number (one read). */
32 latestBlock(): bigint
33 /** Hash of block `n` (one read: header by number). */
34 blockHash(n: bigint): Hex
35 /** eth_calls at one block, batched (readCost(calls) reads). Results in call order. */
36 read(calls: Read[], at: 'latest' | bigint): Hex[]
37 /** Signed report payload -> forwarder -> receiver.onReport. */
38 write(payload: Hex): WriteResult
39}
40
41export interface Ports {
42 chain: Chain
43 /** The attack simulation on every provider (CRE node mode) and the DON's identical-consensus verdict. */
44 simulate(ctx: UpgradeContext, tag: string): Verdict
45 /** The EIP-712 attester key (a CRE secret). */
46 attesterKey(): Hex
47 log(msg: string): void
48}
49
50export type Outcome = {
51 id: string
52 kind: number
53 action: 'cleared' | 'vetoed' | 'skipped' | 'deferred' | 'unconfirmed' | 'failed'
54 reason: string
55 verdict?: 'pass' | 'veto'
56 rule?: RuleId | null
57 evidenceHash?: Hex
58 validUntilBlock?: string
59 tx?: Hex
60}
61
62export type SweepSummary = {
63 block: string
64 window: [string, string] | null
65 candidates: string[]
66 outcomes: Outcome[]
67 readsLeft: number
68 /** Set when the run stopped early (a per-run budget ran out); the next run picks up from the chain state. */
69 error?: string
70}
71
72/** Reads after a report: one batched [getProposal, clearanceOf, oracleJudged] to confirm what the receiver did. */
73const CONFIRM_READS = 1
74/** HTTP calls one attack simulation needs per provider: eth_getCode, then up to `simRequests` eth_simulateV1. */
75export const httpCallsPerProvider = (cfg: Pick<Config, 'simRequests'>) => 1 + cfg.simRequests
76
77const STATUS_NAME: Record<number, string> = { 0: 'None', 1: 'Pending', 2: 'Vetoed', 3: 'Executed' }
78const lc = (a: string) => a.toLowerCase() as Address
79
80type AdminFn = 'getProposal' | 'clearanceOf' | 'oracleJudged' | 'nextId' | 'delayBlocks' | 'isApprovedCodehash'
81const adminRead = (cfg: Pick<Config, 'airlockAdmin'>, functionName: AdminFn, args: readonly unknown[], ret: number): Read => ({
82 to: lc(cfg.airlockAdmin),
83 data: encodeFunctionData({ abi: AIRLOCK_ADMIN_ABI, functionName, args } as never),
84 ret,
85})
86const decodeAdmin = <T>(functionName: string, data: Hex) => decodeFunctionResult({ abi: AIRLOCK_ADMIN_ABI, functionName, data } as never) as T
87
88/** The reads of one proposal's state: getProposal, clearanceOf, oracleJudged. */
89const proposalReads = (cfg: Pick<Config, 'airlockAdmin'>, id: bigint): Read[] => [
90 adminRead(cfg, 'getProposal', [id], 320),
91 adminRead(cfg, 'clearanceOf', [id], 32),
92 adminRead(cfg, 'oracleJudged', [id], 32),
93]
94const PER_PROPOSAL = 3
95
96/** ChainRead calls to read the state of `n` proposals (the sweeper's scan). */
97export const scanCost = (cfg: Pick<Config, 'airlockAdmin'>, n: number): number =>
98 readCost(Array.from({ length: n }, (_, i) => proposalReads(cfg, BigInt(i + 1))).flat())
99
100/** getProposal + clearanceOf + oracleJudged for every id, at the latest block. */
101export function readProposals(chain: Chain, cfg: Config, ids: bigint[]): Proposal[] {
102 const res = chain.read(
103 ids.flatMap((id) => proposalReads(cfg, id)),
104 'latest',
105 )
106 return ids.map((id, i) => {
107 const g = decodeAdmin<{
108 kind: number
109 status: number
110 proposer: Address
111 target: Address
112 value: bigint
113 codehash: Hex
114 dataHash: Hex
115 proposedBlock: bigint
116 readyBlock: bigint
117 vetoCount: bigint
118 }>('getProposal', res[PER_PROPOSAL * i])
119 return {
120 id,
121 kind: Number(g.kind),
122 status: Number(g.status),
123 proposer: lc(g.proposer),
124 target: lc(g.target),
125 value: g.value,
126 codehash: g.codehash.toLowerCase() as Hex,
127 dataHash: g.dataHash.toLowerCase() as Hex,
128 proposedBlock: BigInt(g.proposedBlock),
129 readyBlock: BigInt(g.readyBlock),
130 vetoCount: g.vetoCount,
131 clearance: decodeAdmin<Hex>('clearanceOf', res[PER_PROPOSAL * i + 1]).toLowerCase() as Hex,
132 oracleJudged: decodeAdmin<boolean>('oracleJudged', res[PER_PROPOSAL * i + 2]),
133 }
134 })
135}
136
137/**
138 * The sweeper's work list: Pending proposals no verdict of this workflow has reached, oldest first (closest to their
139 * readyBlock). A clearance by the approver does not take a proposal off the list.
140 */
141export const sweepCandidates = (ps: Proposal[]): Proposal[] =>
142 ps.filter((p) => p.status === STATUS.Pending && !p.oracleJudged).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
143
144/** The newest `window` ids below nextId. */
145export const sweepWindow = (nextId: bigint, window: number): bigint[] => {
146 const first = nextId - BigInt(window) > 1n ? nextId - BigInt(window) : 1n
147 const ids: bigint[] = []
148 for (let id = first; id < nextId; id++) ids.push(id)
149 return ids
150}
151
152const configReads = (cfg: Config, p: Proposal): Read[] => [
153 adminRead(cfg, 'isApprovedCodehash', [actionHash(p.kind, p.target, p.value)], 32),
154 ...(p.kind === KIND.SetDelay ? [adminRead(cfg, 'delayBlocks', [], 32)] : []),
155]
156
157const upgradeReads = (cfg: Config, p: Proposal): Read[] => [
158 ...cfg.holders.map((h) => ({ to: lc(cfg.proxy), data: encodeFunctionData({ abi: TOKEN_ABI, functionName: 'balanceOf', args: [h as Address] }), ret: 32 })),
159 adminRead(cfg, 'isApprovedCodehash', [p.codehash], 32),
160 ...(p.dataHash !== EMPTY_DATA_HASH ? [adminRead(cfg, 'isApprovedCodehash', [upgradeDataKey(p.codehash, p.dataHash)], 32)] : []),
161]
162
163/** Worst-case ChainRead calls to judge and report one proposal (judging reads, block hash, confirmation). */
164export const judgeCost = (cfg: Config, p: Proposal): number =>
165 p.kind === KIND.Upgrade ? readCost(upgradeReads(cfg, p)) + 1 + CONFIRM_READS : readCost(configReads(cfg, p)) + CONFIRM_READS
166
167type Decision = Verdict & { reason: string }
168
169/** Reads at the proposal block, then the verdict. `sims.left` caps attack simulations per run. */
170export function judgeProposal(ports: Ports, cfg: Config, p: Proposal, sims: { left: number }): Decision | { deferred: string } {
171 const { chain } = ports
172 if (p.kind !== KIND.Upgrade) {
173 const r = chain.read(configReads(cfg, p), p.proposedBlock)
174 const approved = decodeAdmin<boolean>('isApprovedCodehash', r[0])
175 const delay = p.kind === KIND.SetDelay ? BigInt(decodeAdmin<bigint>('delayBlocks', r[1])) : null
176 const j = judgeConfig(p, delay, approved)
177 return { ...consensusValue(j), reason: j.reason }
178 }
179
180 const r = chain.read(upgradeReads(cfg, p), p.proposedBlock)
181 const n = cfg.holders.length
182 const balances = r.slice(0, n).map((d) => decodeFunctionResult({ abi: TOKEN_ABI, functionName: 'balanceOf', data: d }))
183 const k = balances.findIndex((b) => b > 0n)
184 const holder = k >= 0 ? lc(cfg.holders[k]) : null
185 const balance = k >= 0 ? balances[k] : 0n
186 const amount = balance / 100n > 0n ? balance / 100n : 1n
187 // A second funded holder (different address) funds the stranger in the simulation, so the test holder's R3
188 // accounting stays clean; fund with the test amount, capped at the funder's balance.
189 const fk = balances.findIndex((b, i) => b > 0n && i !== k && lc(cfg.holders[i]) !== holder)
190 const funder = fk >= 0 ? lc(cfg.holders[fk]) : null
191 const fundAmount = fk >= 0 ? (balances[fk] < amount ? balances[fk] : amount) : 0n
192 const codehashApproved = decodeAdmin<boolean>('isApprovedCodehash', r[n])
193 const hasInitData = p.dataHash !== EMPTY_DATA_HASH
194 const dataApproved = hasInitData ? decodeAdmin<boolean>('isApprovedCodehash', r[n + 1]) : false
195 const s = staticUpgradeCheck({ strict: cfg.strict, codehash: p.codehash, codehashApproved, hasInitData, dataApproved, holder })
196 if (s) return { ...consensusValue(s), reason: s.reason }
197 if (sims.left < 1) return { deferred: 'one attack simulation per run, already used' }
198
199 const blockHash = chain.blockHash(p.proposedBlock)
200 const { stranger, receiver } = proposalActors(BigInt(cfg.chainId), lc(cfg.airlockAdmin), p.id, blockHash)
201 const ctx: UpgradeContext = {
202 proxy: lc(cfg.proxy),
203 newImpl: p.target,
204 codehash: p.codehash,
205 hasInitData,
206 block: p.proposedBlock,
207 holder,
208 amount,
209 funder,
210 fundAmount,
211 holders: cfg.holders.map(lc),
212 watch: cfg.watch.map(lc),
213 stranger,
214 receiver,
215 strict: cfg.strict,
216 codehashApproved,
217 dataApproved,
218 callGas: cfg.callGas,
219 readGas: cfg.readGas,
220 maxCalls: cfg.maxCalls,
221 simRequests: cfg.simRequests,
222 allowedOpcodes: cfg.allowedOpcodes,
223 }
224 sims.left -= 1
225 const v = ports.simulate(ctx, `#${p.id}`)
226 return { ...consensusValue(v), reason: `attack simulation, quorum of ${cfg.minAgree} (provider detail in the node logs)` }
227}
228
229/** Evidence, attestation, report, confirmation. */
230export function deliverVerdict(ports: Ports, cfg: Config, p: Proposal, v: Verdict, currentBlock: bigint): Outcome {
231 const chainId = BigInt(cfg.chainId)
232 const admin = lc(cfg.airlockAdmin)
233 const digest = proposalDigest(p)
234 const verdict = consensusValue(v)
235 const evidence = canonicalEvidence({ chainId, airlockAdmin: admin, proposal: p, proposalDigest: digest, verdict })
236 const evidenceHash = evidenceHashOf(evidence)
237 // Valid until the later of readyBlock and now, plus a grace: deterministic on every DON node, no clock involved.
238 const validUntilBlock = (p.readyBlock > currentBlock ? p.readyBlock : currentBlock) + BigInt(cfg.reportGraceBlocks)
239 const { action, ruleHash } = actionOf(verdict)
240 const signed = buildSignedReport(ports.attesterKey(), chainId, lc(cfg.receiver), admin, {
241 action,
242 proposalId: p.id,
243 proposalDigest: digest,
244 ruleHash,
245 evidenceHash,
246 validUntilBlock,
247 })
248 const name = action === ACTION.Clear ? 'Clear' : 'Veto'
249 ports.log(`#${p.id} evidence ${evidence}`)
250 ports.log(`#${p.id} report ${name} evidenceHash ${evidenceHash} validUntilBlock ${validUntilBlock} digest ${signed.digest}`)
251 const base: Outcome = {
252 id: p.id.toString(),
253 kind: p.kind,
254 action: 'failed',
255 reason: '',
256 verdict: verdict.verdict,
257 rule: verdict.rule,
258 evidenceHash,
259 validUntilBlock: validUntilBlock.toString(),
260 }
261 const w = ports.chain.write(signed.payload)
262 if (w.tx) base.tx = w.tx
263 if (!w.ok) {
264 ports.log(`#${p.id} ${name} report not written: ${w.error ?? 'unknown error'}`)
265 return { ...base, reason: `writeReport failed: ${w.error ?? 'unknown error'}` }
266 }
267 // The forwarder swallows a reverting onReport: a successful transaction is not proof, the proposal state is.
268 // oracleJudged is the receiver's own mark (an approver clearance already makes clearanceOf non-zero). A read that
269 // does not show the verdict cannot tell a rejected report from a node that has not caught up yet, so it is
270 // 'unconfirmed', not a failure: the sweeper re-judges any proposal whose oracleJudged is still false.
271 const [after] = readProposals(ports.chain, cfg, [p.id])
272 const accepted = after.oracleJudged && (action === ACTION.Veto ? after.status === STATUS.Vetoed : after.status !== STATUS.Vetoed)
273 const out: Outcome = {
274 ...base,
275 action: accepted ? (action === ACTION.Veto ? 'vetoed' : 'cleared') : 'unconfirmed',
276 reason: accepted
277 ? `${name} accepted${action === ACTION.Clear && after.clearance !== evidenceHash ? ` (clearanceOf keeps the earlier clearance ${after.clearance})` : ''}`
278 : `${name} sent; the read shows ${STATUS_NAME[after.status]}, oracleJudged ${after.oracleJudged} (rejected, or the node is behind; the sweeper re-checks)`,
279 }
280 ports.log(`#${p.id} ${name} tx ${w.tx ?? '(none)'} -> ${out.action} (status ${STATUS_NAME[after.status]})`)
281 return out
282}
283
284function judgeAndDeliver(ports: Ports, cfg: Config, p: Proposal, currentBlock: bigint, sims: { left: number }): Outcome {
285 const id = p.id.toString()
286 const need = judgeCost(cfg, p)
287 if (ports.chain.readsLeft() < need) {
288 return { id, kind: p.kind, action: 'deferred', reason: `needs ${need} chain reads, ${ports.chain.readsLeft()} left in this run` }
289 }
290 const d = judgeProposal(ports, cfg, p, sims)
291 if ('deferred' in d) {
292 ports.log(`#${id} deferred: ${d.deferred}`)
293 return { id, kind: p.kind, action: 'deferred', reason: d.deferred }
294 }
295 ports.log(`#${id} verdict ${d.verdict}${d.rule ? ` ${d.rule}` : ''} (${d.reason})`)
296 return deliverVerdict(ports, cfg, p, d, currentBlock)
297}
298
299const errMsg = (e: unknown) => ((e as Error)?.message ?? String(e)).slice(0, 300)
300
301/**
302 * Handler 0: a Proposed log. Judges the proposal while it is Pending and this workflow has not judged it yet (a
303 * guardian veto still wins over a clearance; an approver clearance does not count as a verdict).
304 */
305export function handleProposed(ports: Ports, cfg: Config, id: bigint): Outcome {
306 try {
307 const currentBlock = ports.chain.latestBlock()
308 const [p] = readProposals(ports.chain, cfg, [id])
309 if (p.status !== STATUS.Pending || p.oracleJudged) {
310 const reason =
311 p.status === STATUS.None ? 'unknown proposal (log from a reorged block?)' : p.status !== STATUS.Pending ? `proposal is ${STATUS_NAME[p.status]}` : 'already judged by this workflow'
312 ports.log(`#${id} skipped: ${reason}`)
313 return { id: id.toString(), kind: p.kind, action: 'skipped', reason }
314 }
315 return judgeAndDeliver(ports, cfg, p, currentBlock, { left: 1 })
316 } catch (e) {
317 if (!(e instanceof BudgetError)) throw e
318 ports.log(`#${id} deferred: ${errMsg(e)} (the sweeper judges it later)`)
319 return { id: id.toString(), kind: -1, action: 'deferred', reason: errMsg(e) }
320 }
321}
322
323/**
324 * Handler 1: the sweeper. Looks at the newest `sweepWindow` ids and judges every Pending proposal this workflow has
325 * not judged (approver-cleared ones included), oldest first, within this run's budgets: at most one attack
326 * simulation, and only while the chain reads a judgement needs are left. A receiver verdict always sets
327 * oracleJudged, so whatever is left over is picked up by the next run. A budget that runs out anyway ends the run
328 * with a log line and the summary, never with an error.
329 */
330export function handleSweep(ports: Ports, cfg: Config): SweepSummary {
331 const { chain } = ports
332 let currentBlock = 0n
333 let ids: bigint[] = []
334 let todo: Proposal[]
335 try {
336 currentBlock = chain.latestBlock()
337 const nextId = BigInt(decodeAdmin<bigint>('nextId', chain.read([adminRead(cfg, 'nextId', [], 32)], 'latest')[0]))
338 ids = sweepWindow(nextId, cfg.sweepWindow)
339 if (!ids.length) {
340 ports.log('sweep: no proposals yet')
341 return { block: currentBlock.toString(), window: null, candidates: [], outcomes: [], readsLeft: chain.readsLeft() }
342 }
343 todo = sweepCandidates(readProposals(chain, cfg, ids))
344 } catch (e) {
345 if (!(e instanceof BudgetError)) throw e
346 ports.log(`sweep stopped before judging anything: ${errMsg(e)}`)
347 return {
348 block: currentBlock.toString(),
349 window: ids.length ? [ids[0].toString(), ids[ids.length - 1].toString()] : null,
350 candidates: [],
351 outcomes: [],
352 readsLeft: chain.readsLeft(),
353 error: errMsg(e),
354 }
355 }
356 const shown = todo.map((p) => `#${p.id}${p.clearance !== ZERO_HASH ? '(approver-cleared)' : ''}`).join(' ')
357 ports.log(`sweep at block ${currentBlock}: ids ${ids[0]}-${ids[ids.length - 1]}, pending without a workflow verdict: ${shown || 'none'}`)
358 const sims = { left: 1 }
359 const outcomes: Outcome[] = []
360 let exhausted: string | undefined
361 for (const p of todo) {
362 const id = p.id.toString()
363 if (exhausted) {
364 outcomes.push({ id, kind: p.kind, action: 'deferred', reason: exhausted })
365 continue
366 }
367 try {
368 outcomes.push(judgeAndDeliver(ports, cfg, p, currentBlock, sims))
369 } catch (e) {
370 const reason = errMsg(e)
371 if (e instanceof BudgetError) {
372 exhausted = reason
373 ports.log(`#${id} deferred, run budget exhausted: ${reason}`)
374 outcomes.push({ id, kind: p.kind, action: 'deferred', reason })
375 } else {
376 ports.log(`#${id} failed: ${reason}`)
377 outcomes.push({ id, kind: p.kind, action: 'failed', reason })
378 }
379 }
380 }
381 return {
382 block: currentBlock.toString(),
383 window: [ids[0].toString(), ids[ids.length - 1].toString()],
384 candidates: todo.map((p) => p.id.toString()),
385 outcomes,
386 readsLeft: chain.readsLeft(),
387 ...(exhausted ? { error: exhausted } : {}),
388 }
389}