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 / workflow.ts
cre/veto/workflow.ts211 lines · 10826 bytes
1// Upgrade Airlock: the CRE clearance workflow (Chainlink CRE, TypeScript SDK 1.23).
2//
3// Handler 0, EVM log trigger: AirlockAdmin.Proposed at LATEST confidence (the proposal is judged in the block it
4// appears, a whole cooldown before it could execute).
5// Handler 1, cron sweeper (config.sweepSchedule, default every 60 s): judges Pending proposals this workflow has not
6// judged yet (AirlockAdmin.oracleJudged false, approver-cleared ones included), e.g. a log the trigger never
7// delivered (CRE drops log events over 5 kb, such as an upgrade with large init data).
8//
9// Both run airlock.ts:
10// EVM reads (batched through Multicall3): getProposal + clearanceOf + oracleJudged at LATEST; approvals, holder
11// balances, delay at the proposal block; the proposal block's hash (header by number) for the simulation actors
12// -> kinds 1-3: R7 from the reads alone
13// -> kind 0: R5 / R8 / funded holder statically, then node mode: every DON node attack-simulates the upgrade on each
14// configured RPC provider (judge.ts: eth_getCode, then 1 to simRequests eth_simulateV1 requests), the providers
15// are combined by quorum and the DON agrees on the identical {verdict, rule}
16// -> evidenceHash = keccak256(canonical evidence JSON), EIP-712 attestation by the attester (CRE secret),
17// runtime.report -> evm.writeReport -> (Mock)KeystoneForwarder -> AirlockReceiver.onReport
18// -> pass: AirlockAdmin.clear(id, evidenceHash) veto: AirlockAdmin.veto(id, ruleHash) + attachReason
19// -> EVM read of the proposal state (the forwarder swallows a reverting onReport, so tx success is not proof)
20import {
21 bytesToHex,
22 type CronPayload,
23 CronCapability,
24 consensusIdenticalAggregation,
25 EVMClient,
26 encodeCallMsg,
27 getNetwork,
28 handler,
29 HTTPClient,
30 hexToBase64,
31 LATEST_BLOCK_NUMBER,
32 type EVMLog as Log,
33 logTriggerConfig,
34 type NodeRuntime,
35 protoBigIntToBigint,
36 type Runtime,
37 TxStatus,
38 blockNumber as toBlockNumber,
39} from '@chainlink/cre-sdk'
40import { type Address, encodeEventTopics, type Hex, zeroAddress } from 'viem'
41import { AIRLOCK_ADMIN_ABI } from './abi'
42import { type Chain, handleProposed, handleSweep, httpCallsPerProvider, type Ports } from './airlock'
43import { BudgetError, chunkReads, decodeAggregate3, encodeAggregate3, LIMITS } from './chain'
44import { type Config, configSchema } from './config'
45import { consensusValue, type ProviderOutcome, type RpcBatch, simulateUpgrade, type Verdict } from './judge'
46import { stableStringify } from './report'
47
48export { configSchema }
49export type { Config }
50
51export const PROPOSED_TOPIC = encodeEventTopics({ abi: AIRLOCK_ADMIN_ABI, eventName: 'Proposed' })[0] as Hex
52
53/** id, kind and target are indexed: the log's data (init data, possibly large) is never needed. */
54export function decodeProposedLog(log: Log): { id: bigint; kind: number; target: Address; block: bigint; txHash: Hex } {
55 const topics = log.topics.map((t: Uint8Array) => bytesToHex(t))
56 if (topics.length !== 4 || topics[0].toLowerCase() !== PROPOSED_TOPIC.toLowerCase()) throw new Error('not a Proposed log')
57 if (!log.blockNumber) throw new Error('log without block number')
58 return {
59 id: BigInt(topics[1]),
60 kind: Number(BigInt(topics[2])),
61 target: `0x${topics[3].slice(-40)}`.toLowerCase() as Address,
62 block: protoBigIntToBigint(log.blockNumber),
63 txHash: bytesToHex(log.txHash),
64 }
65}
66
67/** Base64 of the UTF-8 bytes (the HTTP capability takes the request body as base64 in its JSON form). */
68export function utf8ToBase64(s: string): string {
69 const bytes = new TextEncoder().encode(s)
70 const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
71 let out = ''
72 for (let i = 0; i < bytes.length; i += 3) {
73 const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
74 out += A[(n >> 18) & 63] + A[(n >> 12) & 63] + (i + 1 < bytes.length ? A[(n >> 6) & 63] : '=') + (i + 2 < bytes.length ? A[n & 63] : '=')
75 }
76 return out
77}
78
79/** JSON-RPC batch over the HTTP capability, results in request order; throws on HTTP or JSON-RPC errors. */
80export const httpRpcBatch =
81 (send: (req: { url: string; method: string; headers: Record<string, string>; body: string }) => { statusCode: number; body: Uint8Array }): RpcBatch =>
82 (url, calls) => {
83 const body = JSON.stringify(calls.map((c, id) => ({ jsonrpc: '2.0', id, method: c.method, params: c.params })))
84 const resp = send({ url, method: 'POST', headers: { 'content-type': 'application/json' }, body: utf8ToBase64(body) })
85 if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`)
86 const parsed = JSON.parse(new TextDecoder().decode(resp.body)) as { id: number; result?: unknown; error?: { message?: string } }[]
87 if (!Array.isArray(parsed) || parsed.length !== calls.length) throw new Error('bad batch response')
88 const byId = new Map(parsed.map((r) => [Number(r.id), r]))
89 return calls.map((c, i) => {
90 const r = byId.get(i)
91 if (!r || r.error || r.result === undefined) throw new Error(`${c.method}: ${r?.error?.message ?? 'no result'}`)
92 return r.result
93 })
94 }
95
96const fmtOutcome = (o: ProviderOutcome) => (o.verdict === 'error' ? `${o.provider}=error(${o.error.slice(0, 80)})` : `${o.provider}=${o.verdict}${o.rule ? `:${o.rule}` : ''}`)
97/** CRE drops log lines over 1 kb. */
98const clip = (s: string) => (s.length > 900 ? `${s.slice(0, 897)}...` : s)
99
100function evmClient(cfg: Config): EVMClient {
101 const network = getNetwork({ chainFamily: 'evm', chainSelectorName: cfg.chainSelectorName })
102 if (!network) throw new Error(`unknown chain ${cfg.chainSelectorName}`)
103 return new EVMClient(network.chainSelector.selector)
104}
105
106/** The EVM capability as a `Chain`, counting every read against the per-run ChainRead limit. */
107export function creChain(runtime: Runtime<Config>, evm: EVMClient): Chain {
108 const cfg = runtime.config
109 let used = 0
110 const take = (what: string) => {
111 if (used + 1 > LIMITS.chainReads) throw new BudgetError(`ChainRead budget of ${LIMITS.chainReads} exhausted (${what})`)
112 used += 1
113 }
114 const ethCall = (to: Address, data: Hex, at: 'latest' | bigint): Hex =>
115 bytesToHex(
116 evm
117 .callContract(runtime, {
118 call: encodeCallMsg({ from: zeroAddress, to, data }),
119 blockNumber: at === 'latest' ? LATEST_BLOCK_NUMBER : toBlockNumber(at),
120 })
121 .result().data,
122 )
123 return {
124 readsLeft: () => LIMITS.chainReads - used,
125 latestBlock: () => {
126 take('latest header')
127 const h = evm.headerByNumber(runtime, {}).result().header
128 if (!h?.blockNumber) throw new Error('no latest header')
129 return protoBigIntToBigint(h.blockNumber)
130 },
131 blockHash: (n) => {
132 take(`header ${n}`)
133 const h = evm.headerByNumber(runtime, { blockNumber: toBlockNumber(n) }).result().header
134 if (!h?.blockNumber || protoBigIntToBigint(h.blockNumber) !== n || h.hash.length !== 32) throw new Error(`no header for block ${n}`)
135 return bytesToHex(h.hash)
136 },
137 read: (calls, at) =>
138 chunkReads(calls).flatMap((chunk) => {
139 take(`${chunk.length} eth_call(s)`)
140 if (chunk.length === 1) return [ethCall(chunk[0].to, chunk[0].data, at)]
141 return decodeAggregate3(ethCall(cfg.multicall as Address, encodeAggregate3(chunk), at), chunk.length)
142 }),
143 write: (payload) => {
144 const report = runtime.report({ encodedPayload: hexToBase64(payload), encoderName: 'evm', signingAlgo: 'ecdsa', hashingAlgo: 'keccak256' }).result()
145 const wr = evm.writeReport(runtime, { receiver: cfg.receiver, report, gasConfig: { gasLimit: cfg.gasLimit } }).result()
146 const tx = wr.txHash && wr.txHash.length ? bytesToHex(wr.txHash) : undefined
147 const ok = wr.txStatus === TxStatus.SUCCESS
148 return { ok, tx, error: ok ? undefined : `tx status ${wr.txStatus}${wr.errorMessage ? `: ${wr.errorMessage}` : ''}` }
149 },
150 }
151}
152
153export function crePorts(runtime: Runtime<Config>): Ports {
154 const cfg = runtime.config
155 const evm = evmClient(cfg)
156 let key: Hex | null = null
157 return {
158 chain: creChain(runtime, evm),
159 log: (m) => runtime.log(clip(m)),
160 attesterKey: () => {
161 if (!key) {
162 const raw = runtime.getSecret({ id: 'VETO_ATTESTER_KEY' }).result().value.trim()
163 key = (raw.startsWith('0x') ? raw : `0x${raw}`) as Hex
164 }
165 return key
166 },
167 simulate: (ctx, tag) => {
168 // the config schema refuses more; this only guards a config that bypassed it
169 if (httpCallsPerProvider(cfg) * cfg.rpcs.length > LIMITS.httpCalls) throw new Error('too many providers for one run')
170 const agreed = runtime
171 .runInNodeMode((node: NodeRuntime<Config>) => {
172 const http = new HTTPClient()
173 const rpc = httpRpcBatch((req) => http.sendRequest(node, req).result())
174 const { judgement: j, outcomes } = simulateUpgrade(rpc, cfg.rpcs, cfg.minAgree, ctx)
175 node.log(clip(`${tag} providers: ${outcomes.map(fmtOutcome).join(' ')}`))
176 const failed = j.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.before} -> ${c.after}`)
177 node.log(
178 clip(
179 `${tag} simulation ${j.verdict}${j.rule ? ` ${j.rule}` : ''} (${j.reason}; ${j.probes} probes over ${j.selectors} selectors in ${j.requests} request(s); R9 opcodes: ${j.flaggedOps.join(' ') || 'none'})${failed.length ? ` | ${failed.join(' | ')}` : ''}`,
180 ),
181 )
182 const culprits = j.culprits.map((c) => `${c.selector}(${c.args}):${c.effects.join('+')}`)
183 if (culprits.length) node.log(clip(`${tag} culprits: ${culprits.join(' ')}`))
184 // Only {verdict, rule}: provider counts, error strings and check detail may differ between nodes.
185 return stableStringify(consensusValue(j))
186 }, consensusIdenticalAggregation<string>())()
187 .result()
188 return JSON.parse(agreed) as Verdict
189 },
190 }
191}
192
193export const onProposed = (runtime: Runtime<Config>, log: Log): string => {
194 if (log.removed) return JSON.stringify({ action: 'skipped', reason: 'removed log (reorg)' })
195 const ev = decodeProposedLog(log)
196 runtime.log(`Proposed #${ev.id} kind ${ev.kind} target ${ev.target} in block ${ev.block} (tx ${ev.txHash})`)
197 return JSON.stringify(handleProposed(crePorts(runtime), runtime.config, ev.id))
198}
199
200export const onSweep = (runtime: Runtime<Config>, _payload: CronPayload): string => JSON.stringify(handleSweep(crePorts(runtime), runtime.config))
201
202export const initWorkflow = (config: Config) => {
203 const evm = evmClient(config)
204 return [
205 handler(
206 evm.logTrigger(logTriggerConfig({ addresses: [config.airlockAdmin as Hex], topics: [[PROPOSED_TOPIC]], confidence: config.logConfidence })),
207 onProposed,
208 ),
209 handler(new CronCapability().trigger({ schedule: config.sweepSchedule }), onSweep),
210 ]
211}