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 / scripts / sepolia / make-config.mjs
scripts/sepolia/make-config.mjs195 lines · 9994 bytes
1#!/usr/bin/env node
2// Deployment JSON (script/DeployEth.s.sol) -> CRE workflow config (the fields of cre/veto/config.ts).
3//
4// bun scripts/sepolia/make-config.mjs [deployment.json] [--out file] [--providers a,b,c] [--rpc label=url ...]
5// (node runs it too, but only bun can load cre/veto/config.ts to check the result against the workflow's schema)
6// FORK=19631 bun scripts/sepolia/make-config.mjs # the fork rehearsal's config, written to $TMPDIR
7// bun scripts/sepolia/make-config.mjs --example
8//
9// Defaults: deployments/sepolia.json -> cre/veto/config.sepolia.json, with four independent providers
10// (publicnode, drpc, thirdweb, 1rpc) and minAgree 2: at least two must answer and every answer must agree
11// (cre/veto/judge.ts). 1rpc is kept although it is often rate-limited (an error only costs a little time).
12// --providers list a comma-separated pick of the known providers: publicnode, drpc, thirdweb, 1rpc, 0xrpc
13// (e.g. publicnode,drpc,thirdweb; probe them first with probe-rpcs.ts)
14// --rpc label=url any provider by URL (repeat it; replaces --providers)
15// --min-agree n quorum size (default 2)
16// --fork-rpc url every provider points at one local fork (--out is then required, so the fork config never
17// lands in the repo). FORK=<port> sets it, the fork deployment and the output in $TMPDIR.
18// --example writes cre/veto/config.sepolia.example.json with placeholder addresses (commit-safe)
19// --sweep-schedule cron schedule of the sweeper trigger (default "0 * * * * *": every 60 s; CRE runs a cron at most every 30 s)
20// --sweep-window n how many of the newest proposal ids each sweep looks at (default 20)
21// The result is checked against the workflow's own zod schema (cre/veto/config.ts) when it can be loaded.
22import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23import { dirname, isAbsolute, relative, resolve } from 'node:path'
24import { fileURLToPath } from 'node:url'
25
26const die = (msg) => {
27 console.error(`make-config: ${msg}`)
28 process.exit(1)
29}
30const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
31const SEPOLIA = 11155111
32// Public Sepolia endpoints that accept JSON-RPC batches with eth_simulateV1 (checked with probe-rpcs.ts).
33const KNOWN_RPCS = {
34 publicnode: 'https://ethereum-sepolia-rpc.publicnode.com',
35 drpc: 'https://sepolia.drpc.org',
36 thirdweb: 'https://11155111.rpc.thirdweb.com',
37 '1rpc': 'https://1rpc.io/sepolia',
38 '0xrpc': 'https://0xrpc.io/sep',
39}
40const pick = (labels) =>
41 labels.map((label) => {
42 if (!KNOWN_RPCS[label]) die(`unknown provider "${label}" (known: ${Object.keys(KNOWN_RPCS).join(', ')}; or use --rpc label=url)`)
43 return { url: KNOWN_RPCS[label], label }
44 })
45const DEFAULT_RPCS = pick(['publicnode', 'drpc', 'thirdweb', '1rpc'])
46// Same values the fork E2E (cre/scripts/fork-e2e.sh) runs with.
47const TUNING = {
48 strict: false,
49 callGas: 1_000_000,
50 readGas: 200_000,
51 maxCalls: 150, // simulated calls per eth_simulateV1 request: the demo token's probes go out as two requests
52 simRequests: 2, // eth_simulateV1 requests per provider: 1 + 2 HTTP calls each, 12 of the 15 per run with four
53 allowedOpcodes: [], // R9: no opcode is exempt; only an approved codehash is
54 gasLimit: '2000000',
55 reportGraceBlocks: 50,
56 logConfidence: 'LATEST', // the workflow accepts no other value
57}
58// The sweeper (trigger 1): judges proposals the log trigger missed. Field names as in cre/veto/config.ts.
59const SWEEP = { schedule: '0 * * * * *', window: 20 }
60// DeployEth.s.sol's keyless demo holders: keccak256("upgrade-airlock/demo-holder/<i>"), identical on every deployment.
61const DEMO_HOLDERS = [
62 '0x50D70Ff5338289b11E91A0fF5336DB29BDd510e0',
63 '0x01B22bF1E81f3563727E0F7BD6c2415B87136Da5',
64 '0x22421cA36b30D2D4Db5c32fEd40505530D52Ea22',
65]
66
67const isAddr = (a) => typeof a === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a)
68const isUrl = (u) => /^https?:\/\/[^\s]+$/.test(u)
69
70function parseArgs(argv) {
71 const o = { input: null, out: null, forkRpc: null, rpcs: [], providers: null, minAgree: 2, example: false, sweepSchedule: SWEEP.schedule, sweepWindow: SWEEP.window }
72 for (let i = 0; i < argv.length; i++) {
73 const a = argv[i]
74 const next = () => argv[++i] ?? die(`${a} needs a value`)
75 if (a === '--out') o.out = next()
76 else if (a === '--fork-rpc') o.forkRpc = next()
77 else if (a === '--rpc') {
78 const v = next()
79 const eq = v.indexOf('=')
80 if (eq < 1) die(`--rpc wants label=url, got "${v}"`)
81 o.rpcs.push({ label: v.slice(0, eq), url: v.slice(eq + 1) })
82 } else if (a === '--providers') o.providers = next().split(',').map((x) => x.trim()).filter(Boolean)
83 else if (a === '--min-agree') o.minAgree = Number(next())
84 else if (a === '--example') o.example = true
85 else if (a === '--sweep-schedule') o.sweepSchedule = next()
86 else if (a === '--sweep-window') o.sweepWindow = Number(next())
87 else if (a === '-h' || a === '--help') {
88 console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 21).join('\n'))
89 process.exit(0)
90 } else if (a.startsWith('-')) die(`unknown option ${a}`)
91 else if (!o.input) o.input = a
92 else die(`unexpected argument ${a}`)
93 }
94 return o
95}
96
97function fromDeployment(d, o, rpcs) {
98 if (Number(d.chainId) !== SEPOLIA) die(`deployment chainId is ${d.chainId}, expected ${SEPOLIA} (Sepolia)`)
99 for (const k of ['airlockAdmin', 'receiver', 'proxy', 'owner']) if (!isAddr(d[k])) die(`deployment has no valid "${k}"`)
100 if (!Array.isArray(d.holders) || !d.holders.length || !d.holders.every(isAddr)) die('deployment has no valid "holders"')
101 return {
102 chainSelectorName: 'ethereum-testnet-sepolia',
103 chainId: SEPOLIA,
104 airlockAdmin: d.airlockAdmin,
105 receiver: d.receiver,
106 proxy: d.proxy,
107 holders: d.holders,
108 watch: [d.owner, d.airlockAdmin], // the workflow adds every holder to the watch list itself
109 rpcs,
110 minAgree: o.minAgree,
111 ...TUNING,
112 sweepSchedule: o.sweepSchedule,
113 sweepWindow: o.sweepWindow,
114 }
115}
116
117function check(cfg) {
118 if (!Number.isInteger(cfg.minAgree) || cfg.minAgree < 1) die('minAgree must be a positive integer')
119 if (!Number.isInteger(cfg.sweepWindow) || cfg.sweepWindow < 1) die('--sweep-window must be a positive integer')
120 if (cfg.sweepSchedule.trim().split(/\s+/).length < 5) die(`--sweep-schedule "${cfg.sweepSchedule}" is not a cron expression`)
121 if (cfg.rpcs.length < cfg.minAgree) die(`${cfg.rpcs.length} providers cannot reach a ${cfg.minAgree}-provider quorum`)
122 if (cfg.rpcs.length > 5) die('at most 5 providers (cre/veto/config.ts)')
123 for (const r of cfg.rpcs) if (!isUrl(r.url) || !r.label) die(`bad provider ${JSON.stringify(r)}`)
124 const labels = new Set(cfg.rpcs.map((r) => r.label))
125 if (labels.size !== cfg.rpcs.length) die('provider labels must be unique')
126}
127
128async function schemaCheck(cfg) {
129 // The workflow's own schema; needs the workflow deps (cre/setup.sh) and a TypeScript-capable runtime (bun, node >= 22.18).
130 try {
131 const { configSchema } = await import(resolve(ROOT, 'cre/veto/config.ts'))
132 const r = configSchema.safeParse(cfg)
133 if (!r.success) die(`config does not match cre/veto/config.ts: ${r.error.message}`)
134 return 'checked against cre/veto/config.ts'
135 } catch (e) {
136 if (e?.code === 'ERR_UNKNOWN_FILE_EXTENSION' || e?.code === 'ERR_MODULE_NOT_FOUND') return 'schema not loaded (run with bun to check it)'
137 throw e
138 }
139}
140
141const show = (p) => (isAbsolute(p) && !relative(ROOT, p).startsWith('..') ? relative(ROOT, p) : p)
142
143const o = parseArgs(process.argv.slice(2))
144let cfg
145let out
146if (o.example) {
147 const ph = (n) => `0x${n.repeat(40)}`
148 cfg = {
149 chainSelectorName: 'ethereum-testnet-sepolia',
150 chainId: SEPOLIA,
151 airlockAdmin: ph('1'), // AirlockAdmin (deployments/sepolia.json: airlockAdmin)
152 receiver: ph('2'), // AirlockReceiver (clearance oracle and guardian)
153 proxy: ph('3'), // the DemoBTC proxy
154 holders: DEMO_HOLDERS,
155 watch: [ph('4'), ph('1')], // [owner, airlockAdmin]
156 rpcs: DEFAULT_RPCS,
157 minAgree: 2,
158 ...TUNING,
159 sweepSchedule: o.sweepSchedule,
160 sweepWindow: o.sweepWindow,
161 }
162 out = resolve(ROOT, o.out ?? 'cre/veto/config.sepolia.example.json')
163} else {
164 if (process.env.FORK) {
165 if (!/^\d{2,5}$/.test(process.env.FORK)) die('FORK must be a port number')
166 const state = resolve((process.env.TMPDIR || '/tmp').replace(/\/$/, ''), `upgrade-airlock-fork-${process.env.FORK}`)
167 o.input ??= resolve(state, 'deployment.json')
168 o.forkRpc ??= `http://127.0.0.1:${process.env.FORK}`
169 o.out ??= resolve(state, 'config.fork.json')
170 }
171 const input = resolve(o.input ?? resolve(ROOT, 'deployments/sepolia.json'))
172 if (!existsSync(input)) die(`no deployment JSON at ${show(input)} (run scripts/sepolia/deploy.sh first)`)
173 let d
174 try {
175 d = JSON.parse(readFileSync(input, 'utf8'))
176 } catch (e) {
177 die(`${show(input)} is not JSON: ${e.message}`)
178 }
179 let rpcs = o.rpcs.length ? o.rpcs : o.providers ? pick(o.providers) : DEFAULT_RPCS
180 if (o.forkRpc) {
181 if (!/^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(o.forkRpc)) die('--fork-rpc must be http://127.0.0.1:<port>')
182 if (!o.out) die('--fork-rpc needs --out (keep the fork config outside the repo)')
183 rpcs = ['fork-a', 'fork-b', 'fork-c'].map((label) => ({ url: o.forkRpc, label }))
184 }
185 cfg = fromDeployment(d, o, rpcs)
186 out = resolve(o.out ?? resolve(ROOT, 'cre/veto/config.sepolia.json'))
187}
188check(cfg)
189const how = await schemaCheck(cfg)
190mkdirSync(dirname(out), { recursive: true })
191writeFileSync(out, `${JSON.stringify(cfg, null, 2)}\n`)
192console.log(`wrote ${show(out)} (${how})`)
193console.log(` airlockAdmin ${cfg.airlockAdmin} receiver ${cfg.receiver} proxy ${cfg.proxy}`)
194console.log(` providers ${cfg.rpcs.map((r) => `${r.label}=${r.url}`).join(' ')} quorum ${cfg.minAgree}-of-${cfg.rpcs.length}`)
195console.log(` sweeper "${cfg.sweepSchedule}" over the last ${cfg.sweepWindow} proposal ids`)