cre/veto/config.ts74 lines · 4548 bytes
| 1 | import { z } from 'zod' |
| 2 | import { MULTICALL3_ADDRESS } from './abi' |
| 3 | import { DANGEROUS_OPCODE_NAMES } from './bytecode' |
| 4 | import { 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 | */ |
| 12 | export const MAX_SWEEP_WINDOW = 30 |
| 13 | |
| 14 | const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/) |
| 15 | |
| 16 | export 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 | |
| 74 | export type Config = z.infer<typeof configSchema> |