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 / config.ts
cre/veto/config.ts74 lines · 4548 bytes
1import { z } from 'zod'
2import { MULTICALL3_ADDRESS } from './abi'
3import { DANGEROUS_OPCODE_NAMES } from './bytecode'
4import { LIMITS } from './chain'
5
6/**
7 * Largest sweepWindow. A sweep costs 2 ChainReads (latest header, nextId), the scan of the window (getProposal,
8 * clearanceOf and oracleJudged per id, batched: 6 reads for 30 ids, 8 for 40, 16 for 79), then 3 per upgrade and 2
9 * per config change it judges, out of 15. At 30, 2 + 6 = 8 leaves 7: an upgrade and two config changes in every
10 * run, with room to spare. test/airlock.test.ts recomputes it.
11 */
12export const MAX_SWEEP_WINDOW = 30
13
14const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/)
15
16export const configSchema = z
17 .object({
18 chainSelectorName: z.string(), // e.g. "ethereum-testnet-sepolia"
19 chainId: z.number().int().positive(), // 11155111 for Sepolia (the EIP-712 domain of the receiver)
20 airlockAdmin: address,
21 /** AirlockReceiver: AirlockAdmin's clearance oracle and one of its guardians. */
22 receiver: address,
23 proxy: address,
24 /** Token holders. All of them are watched in the simulation; the first with a balance makes the R3/R4 transfer. */
25 holders: z.array(address).min(1).max(8),
26 /** Extra addresses whose balances must not move (e.g. the token owner). */
27 watch: z.array(address).max(8).default([]),
28 /** Independent RPC providers for the attack simulation (each must support eth_simulateV1 + batch requests). */
29 // regex, not z.string().url(): zod's url check relies on the URL global, which the CRE (QuickJS) runtime lacks
30 rpcs: z.array(z.object({ url: z.string().regex(/^https?:\/\/[^\s]+$/), label: z.string().min(1) })).min(1).max(5),
31 minAgree: z.number().int().positive(),
32 strict: z.boolean().default(false),
33 callGas: z.number().int().positive().default(1_000_000),
34 readGas: z.number().int().positive().default(200_000),
35 /** Simulated calls per eth_simulateV1 request (each request also stays under the HTTP request size limit). */
36 maxCalls: z.number().int().min(80).max(400).default(250),
37 /**
38 * eth_simulateV1 requests per provider and proposal when the probes do not fit in one. Every provider costs
39 * 1 + simRequests HTTP calls (eth_getCode first), within the 15 per run: 2 allows up to 5 providers.
40 */
41 simRequests: z.number().int().min(1).max(4).default(2),
42 /**
43 * R9 opcodes that do not veto an upgrade (names as in bytecode.ts DANGEROUS_OPCODES, e.g. "STATICCALL"). Default
44 * none: any such opcode in the new code vetoes unless its codehash is approved.
45 */
46 allowedOpcodes: z.array(z.enum(DANGEROUS_OPCODE_NAMES)).max(DANGEROUS_OPCODE_NAMES.length).default([]),
47 /**
48 * Gas limit for the forwarder -> receiver.onReport -> AirlockAdmin.veto + attachReason / clear path. A Veto report
49 * used up to about 524k gas in the fork end-to-end run, a Clear about 397k: the default leaves room for both.
50 */
51 gasLimit: z.string().regex(/^\d+$/).default('2000000'),
52 /** A verdict is accepted on-chain until max(readyBlock, current block) + this many blocks (no clock involved). */
53 reportGraceBlocks: z.number().int().nonnegative().default(50),
54 /** The log trigger only runs at LATEST: a proposal is judged in the block it appears, long before readyBlock. */
55 logConfidence: z.literal('LATEST').default('LATEST'),
56 /** Sweeper cron schedule (6 fields with seconds, or 5). CRE's fastest allowed interval is 30 s. */
57 sweepSchedule: z
58 .string()
59 .regex(/^\S+( \S+){4,5}$/)
60 .default('0 * * * * *'),
61 /** The sweeper looks at the newest `sweepWindow` proposal ids (at most MAX_SWEEP_WINDOW, see above). */
62 sweepWindow: z.number().int().min(1).max(MAX_SWEEP_WINDOW).default(20),
63 /** Multicall3, used to batch reads under the per-run ChainRead budget. */
64 multicall: address.default(MULTICALL3_ADDRESS),
65 })
66 .refine((c) => c.minAgree <= c.rpcs.length, { message: 'minAgree is larger than the number of rpcs', path: ['minAgree'] })
67 .refine((c) => new Set(c.rpcs.map((r) => r.label)).size === c.rpcs.length, { message: 'rpc labels must be unique', path: ['rpcs'] })
68 .refine((c) => c.rpcs.length * (1 + c.simRequests) <= LIMITS.httpCalls, {
69 message: `rpcs x (1 + simRequests) must stay within ${LIMITS.httpCalls} HTTP calls per run`,
70 path: ['simRequests'],
71 })
72 .refine((c) => new Set(c.allowedOpcodes).size === c.allowedOpcodes.length, { message: 'allowedOpcodes lists an opcode twice', path: ['allowedOpcodes'] })
73
74export type Config = z.infer<typeof configSchema>