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) |
| 20 | import { |
| 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' |
| 40 | import { type Address, encodeEventTopics, type Hex, zeroAddress } from 'viem' |
| 41 | import { AIRLOCK_ADMIN_ABI } from './abi' |
| 42 | import { type Chain, handleProposed, handleSweep, httpCallsPerProvider, type Ports } from './airlock' |
| 43 | import { BudgetError, chunkReads, decodeAggregate3, encodeAggregate3, LIMITS } from './chain' |
| 44 | import { type Config, configSchema } from './config' |
| 45 | import { consensusValue, type ProviderOutcome, type RpcBatch, simulateUpgrade, type Verdict } from './judge' |
| 46 | import { stableStringify } from './report' |
| 47 | |
| 48 | export { configSchema } |
| 49 | export type { Config } |
| 50 | |
| 51 | export 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. */ |
| 54 | export 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). */ |
| 68 | export 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. */ |
| 80 | export 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 | |
| 96 | const 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. */ |
| 98 | const clip = (s: string) => (s.length > 900 ? `${s.slice(0, 897)}...` : s) |
| 99 | |
| 100 | function 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. */ |
| 107 | export 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 | |
| 153 | export 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 | |
| 193 | export 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 | |
| 200 | export const onSweep = (runtime: Runtime<Config>, _payload: CronPayload): string => JSON.stringify(handleSweep(crePorts(runtime), runtime.config)) |
| 201 | |
| 202 | export 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 | } |