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. |
| 21 | import { type Address, type Hex, keccak256 } from 'viem' |
| 22 | import { KIND } from './abi' |
| 23 | import { dangerousOpcodes } from './bytecode' |
| 24 | import type { RuleId } from './rules' |
| 25 | import { buildSimulation, type Check, type Evaluation, evaluateSimulation, ViewRevert } from './simulate' |
| 26 | |
| 27 | export 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). */ |
| 29 | export type RpcBatch = (url: string, calls: RpcCall[]) => unknown[] |
| 30 | |
| 31 | /** The consensus value: deterministic on every honest DON node. */ |
| 32 | export 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). */ |
| 35 | export 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 | |
| 47 | export 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 | |
| 76 | export 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 | |
| 90 | export const consensusValue = (j: Verdict): Verdict => ({ verdict: j.verdict, rule: j.rule }) |
| 91 | |
| 92 | const 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 | }) |
| 103 | const 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 | */ |
| 122 | export 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. */ |
| 138 | export 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. */ |
| 152 | export 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 | |
| 157 | export 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). */ |
| 223 | export 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). */ |
| 234 | export 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 | |
| 244 | export 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 | } |