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 / judge.ts
cre/veto/judge.ts277 lines · 12426 bytes
1// Verdicts. Pure apart from the injected `rpc` function, so the same code runs in Bun tests and inside a CRE
2// node-mode call (where `rpc` is backed by the HTTP capability).
3//
4// Config proposals (kinds 1-3): R7 only, from two chain reads (judgeConfig).
5// Upgrades: static rules first (R5 strict codehash, R8 init data, a funded holder must exist), then the attack
6// simulation on every configured RPC provider, pinned to the proposal's block, with 1 + up to `simRequests` HTTP
7// requests each:
8// 1. eth_getCode(newImpl) -> the code must hash to the proposed codehash (else R6)
9// 2. eth_simulateV1, split over as many requests as the probes need (simulate.ts) -> evaluateSimulation (R1-R4,
10// R3B). Probes that do not fit in `simRequests` requests are R6, unless the codehash is approved: then only that
11// truncation is excused and every other rule still runs on the probes that fit.
12// 3. R9: block/fee context, external call, create and selfdestruct opcodes in the new code that the config's
13// `allowedOpcodes` does not list veto, unless the codehash is approved. The list is fixed in the config, so an
14// approval of one implementation's codehash never exempts a later one.
15// Quorum: at least `minAgree` providers answer and every answer is the same verdict (pass, or veto with one rule).
16// Anything else fails closed as R6_SIM_FAILURE: a wrong veto only delays an upgrade, a wrong pass could let an
17// attacker in.
18//
19// The DON agrees only on `Verdict` ({verdict, rule}); everything else in a `Judgement` (checks, culprits, opcode
20// lists, probe counts) is detail for the logs.
21import { type Address, type Hex, keccak256 } from 'viem'
22import { KIND } from './abi'
23import { dangerousOpcodes } from './bytecode'
24import type { RuleId } from './rules'
25import { buildSimulation, type Check, type Evaluation, evaluateSimulation, ViewRevert } from './simulate'
26
27export type RpcCall = { method: string; params: unknown[] }
28/** Sends one JSON-RPC batch to provider `url` and returns the results in request order (throws on any error). */
29export type RpcBatch = (url: string, calls: RpcCall[]) => unknown[]
30
31/** The consensus value: deterministic on every honest DON node. */
32export type Verdict = { verdict: 'pass' | 'veto'; rule: RuleId | null }
33
34/** A verdict plus the detail that explains it (logged, never part of the consensus value or the evidence). */
35export type Judgement = Verdict & {
36 reason: string
37 checks: Check[]
38 culprits: Evaluation['culprits']
39 /** R9 opcodes in the new code that the allowlist does not cover */
40 flaggedOps: string[]
41 probes: number
42 selectors: number
43 /** eth_simulateV1 requests per provider */
44 requests: number
45}
46
47export type UpgradeContext = {
48 proxy: Address
49 newImpl: Address
50 codehash: Hex
51 hasInitData: boolean
52 block: bigint
53 holder: Address | null // the first configured holder with a positive balance at the proposal block, or null
54 amount: bigint
55 /** A second configured holder (never `holder`) with a balance, whose tokens fund the stranger; null/absent when none. */
56 funder?: Address | null
57 /** Tokens moved from `funder` to the stranger (<= the funder's balance at the proposal block). */
58 fundAmount?: bigint
59 holders: Address[]
60 watch: Address[]
61 stranger: Address
62 receiver: Address
63 strict: boolean
64 codehashApproved: boolean
65 dataApproved: boolean
66 callGas: number
67 readGas: number
68 /** Simulated calls per eth_simulateV1 request. */
69 maxCalls: number
70 /** eth_simulateV1 requests per provider (default 2). */
71 simRequests?: number
72 /** R9 opcode names that do not veto (default none). */
73 allowedOpcodes?: readonly string[]
74}
75
76export type ProviderOutcome =
77 | {
78 provider: string
79 verdict: 'pass' | 'veto'
80 rule: RuleId | null
81 checks: Check[]
82 culprits: Evaluation['culprits']
83 flaggedOps: string[]
84 probes: number
85 selectors: number
86 requests: number
87 }
88 | { provider: string; verdict: 'error'; error: string }
89
90export const consensusValue = (j: Verdict): Verdict => ({ verdict: j.verdict, rule: j.rule })
91
92const vetoJ = (rule: RuleId, reason: string, checks: Check[] = []): Judgement => ({
93 verdict: 'veto',
94 rule,
95 reason,
96 checks,
97 culprits: [],
98 flaggedOps: [],
99 probes: 0,
100 selectors: 0,
101 requests: 0,
102})
103const passJ = (reason: string, checks: Check[] = []): Judgement => ({
104 verdict: 'pass',
105 rule: null,
106 reason,
107 checks,
108 culprits: [],
109 flaggedOps: [],
110 probes: 0,
111 selectors: 0,
112 requests: 0,
113})
114
115/**
116 * R7: a config change that weakens the airlock needs the approver's pre-approval of
117 * keccak256(abi.encode(kind, target, value)) (read at the proposal block).
118 * - SetGuardian: always (adding one grants veto and breakGlass power, removing one drops a veto voice);
119 * - SetDelay: only a shorter delay;
120 * - SetPausedImpl: always (breakGlass switches to it with no cooldown).
121 */
122export function judgeConfig(p: { kind: number; target: Address; value: bigint }, currentDelay: bigint | null, approved: boolean): Judgement {
123 let weakening = true
124 let what: string
125 if (p.kind === KIND.SetGuardian) what = p.value === 0n ? `remove guardian ${p.target}` : `add guardian ${p.target}`
126 else if (p.kind === KIND.SetDelay) {
127 if (currentDelay === null) throw new Error('SetDelay needs the current delay')
128 weakening = p.value < currentDelay
129 what = `delay ${currentDelay} -> ${p.value}`
130 } else if (p.kind === KIND.SetPausedImpl) what = `paused impl -> ${p.target}`
131 else throw new Error(`not a config proposal kind: ${p.kind}`)
132 const ok = !weakening || approved
133 const check: Check = { name: 'R7_CONFIG_GUARD', before: what, after: !weakening ? 'not weakening' : approved ? 'approved' : 'not approved', ok }
134 return ok ? passJ(`config change: ${what} (${check.after})`, [check]) : vetoJ('R7_CONFIG_GUARD', `config change without approval: ${what}`, [check])
135}
136
137/** Upgrade rules that need no simulation. Returns null when the simulation must decide. */
138export function staticUpgradeCheck(ctx: Pick<UpgradeContext, 'strict' | 'codehashApproved' | 'codehash' | 'hasInitData' | 'dataApproved' | 'holder'>): Judgement | null {
139 if (ctx.strict && !ctx.codehashApproved) {
140 return vetoJ('R5_STRICT_CODEHASH', 'strict mode: codehash not approved', [{ name: 'R5_STRICT_CODEHASH', before: ctx.codehash, after: 'not approved', ok: false }])
141 }
142 if (ctx.hasInitData && !ctx.dataApproved) {
143 return vetoJ('R8_INIT_DATA', 'init data without codehash+data approval', [{ name: 'R8_INIT_DATA', before: 'init data', after: 'codehash+data not approved', ok: false }])
144 }
145 if (!ctx.holder) {
146 return vetoJ('R6_SIM_FAILURE', 'no configured holder has a balance', [{ name: 'R6_SIM_FAILURE', before: 'holders', after: 'every configured holder has balance 0', ok: false }])
147 }
148 return null
149}
150
151/** R9: dangerous opcodes in `code` that `allowed` does not list. */
152export const r9Flagged = (code: Hex, allowed: readonly string[] = []): string[] => {
153 const ok = new Set(allowed)
154 return dangerousOpcodes(code).filter((o) => !ok.has(o))
155}
156
157export function judgeOnProvider(rpc: RpcBatch, url: string, label: string, ctx: UpgradeContext): ProviderOutcome {
158 const r6 = (before: string, after: string, probes = 0, selectors = 0): ProviderOutcome => ({
159 provider: label,
160 verdict: 'veto',
161 rule: 'R6_SIM_FAILURE',
162 checks: [{ name: 'R6_SIM_FAILURE', before, after, ok: false }],
163 culprits: [],
164 flaggedOps: [],
165 probes,
166 selectors,
167 requests: 0,
168 })
169 try {
170 const blockTag = `0x${ctx.block.toString(16)}`
171 const [newCode] = rpc(url, [{ method: 'eth_getCode', params: [ctx.newImpl, blockTag] }]) as [Hex]
172 if (!newCode || newCode === '0x' || keccak256(newCode) !== ctx.codehash.toLowerCase()) return r6(ctx.codehash, 'code missing or codehash mismatch')
173 const { requests, layout } = buildSimulation({
174 proxy: ctx.proxy,
175 newImpl: ctx.newImpl,
176 newCode,
177 holder: ctx.holder as Address,
178 amount: ctx.amount,
179 funder: ctx.funder ?? null,
180 fundAmount: ctx.fundAmount ?? 0n,
181 holders: ctx.holders,
182 watch: ctx.watch,
183 stranger: ctx.stranger,
184 receiver: ctx.receiver,
185 block: ctx.block,
186 callGas: ctx.callGas,
187 readGas: ctx.readGas,
188 maxCalls: ctx.maxCalls,
189 maxRequests: ctx.simRequests ?? 2,
190 })
191 const fit = `${layout.probes} of ${layout.wantedProbes} probes fit in ${layout.chunks.length} request(s) of at most ${ctx.maxCalls} calls`
192 // Too many probes: R6, unless the codehash is approved (then only this is excused; every other rule still runs).
193 if (layout.truncated && !ctx.codehashApproved) return r6(`${layout.wantedProbes} probes needed`, `only ${fit}`, layout.probes, layout.selectors.length)
194 const results = requests.map((params) => rpc(url, [{ method: 'eth_simulateV1', params }])[0])
195
196 let ev: Evaluation
197 try {
198 ev = evaluateSimulation(results, layout)
199 } catch (e) {
200 if (e instanceof ViewRevert) return r6('view', e.message.slice(0, 160), layout.probes, layout.selectors.length)
201 throw e
202 }
203 const flaggedOps = r9Flagged(newCode, ctx.allowedOpcodes)
204 let { verdict, rule } = ev
205 const checks = [...ev.checks]
206 if (layout.truncated) checks.push({ name: 'R6_SIM_FAILURE', before: `${layout.wantedProbes} probes needed`, after: `${fit} (codehash approved)`, ok: true })
207 if (verdict === 'pass' && flaggedOps.length) {
208 const ok9 = ctx.codehashApproved
209 const allowed = [...(ctx.allowedOpcodes ?? [])].sort().join(' ') || 'none'
210 checks.push({ name: 'R9_NEW_OPCODES', before: `allowed: ${allowed}`, after: `found: ${flaggedOps.join(' ')}${ok9 ? ' (codehash approved)' : ''}`, ok: ok9 })
211 if (!ok9) {
212 verdict = 'veto'
213 rule = 'R9_NEW_OPCODES'
214 }
215 }
216 return { provider: label, verdict, rule, checks, culprits: ev.culprits, flaggedOps, probes: ev.probes, selectors: layout.selectors.length, requests: requests.length }
217 } catch (e) {
218 return { provider: label, verdict: 'error', error: (e as Error).message?.slice(0, 200) ?? String(e) }
219 }
220}
221
222/** Every provider, then the quorum (no static rules: call staticUpgradeCheck first). */
223export function simulateUpgrade(
224 rpc: RpcBatch,
225 providers: { url: string; label: string }[],
226 minAgree: number,
227 ctx: UpgradeContext,
228): { judgement: Judgement; outcomes: ProviderOutcome[] } {
229 const outcomes = providers.map((p) => judgeOnProvider(rpc, p.url, p.label, ctx))
230 return { judgement: quorum(outcomes, minAgree), outcomes }
231}
232
233/** Static rules, then the simulation (for off-chain tools that judge an upgrade in one call). */
234export function judgeUpgrade(
235 rpc: RpcBatch,
236 providers: { url: string; label: string }[],
237 minAgree: number,
238 ctx: UpgradeContext,
239): { judgement: Judgement; outcomes: ProviderOutcome[] } {
240 const s = staticUpgradeCheck(ctx)
241 return s ? { judgement: s, outcomes: [] } : simulateUpgrade(rpc, providers, minAgree, ctx)
242}
243
244export function quorum(outcomes: ProviderOutcome[], minAgree: number): Judgement {
245 // Every answered provider must agree, and at least `minAgree` must have answered. Honest providers simulate the
246 // same code at the same pinned block, so any disagreement means a provider is wrong or lying: fail closed.
247 // A single bad provider can therefore cause a veto (a delay), never a pass.
248 const groups = new Map<string, Extract<ProviderOutcome, { verdict: 'pass' | 'veto' }>[]>()
249 for (const o of outcomes) {
250 if (o.verdict === 'error') continue
251 const key = `${o.verdict}:${o.rule ?? ''}`
252 groups.set(key, [...(groups.get(key) ?? []), o])
253 }
254 if (groups.size > 1) {
255 return vetoJ('R6_SIM_FAILURE', 'providers disagree', [
256 { name: 'R6_SIM_FAILURE', before: 'all answering providers must agree', after: [...groups.keys()].sort().join(' vs '), ok: false },
257 ])
258 }
259 const best = [...groups.values()][0]
260 if (!best || best.length < minAgree) {
261 return vetoJ('R6_SIM_FAILURE', `fewer than ${minAgree} providers answered`, [
262 { name: 'R6_SIM_FAILURE', before: `${minAgree} of ${outcomes.length} providers must answer`, after: 'not enough answers', ok: false },
263 ])
264 }
265 const o = best[0]
266 return {
267 verdict: o.verdict,
268 rule: o.rule,
269 reason: `quorum of ${minAgree}`,
270 checks: o.checks,
271 culprits: o.culprits,
272 flaggedOps: o.flaggedOps,
273 probes: o.probes,
274 selectors: o.selectors,
275 requests: o.requests,
276 }
277}