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. |
| 22 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 23 | import { dirname, isAbsolute, relative, resolve } from 'node:path' |
| 24 | import { fileURLToPath } from 'node:url' |
| 25 | |
| 26 | const die = (msg) => { |
| 27 | console.error(`make-config: ${msg}`) |
| 28 | process.exit(1) |
| 29 | } |
| 30 | const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..') |
| 31 | const SEPOLIA = 11155111 |
| 32 | // Public Sepolia endpoints that accept JSON-RPC batches with eth_simulateV1 (checked with probe-rpcs.ts). |
| 33 | const 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 | } |
| 40 | const 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 | }) |
| 45 | const DEFAULT_RPCS = pick(['publicnode', 'drpc', 'thirdweb', '1rpc']) |
| 46 | // Same values the fork E2E (cre/scripts/fork-e2e.sh) runs with. |
| 47 | const 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. |
| 59 | const SWEEP = { schedule: '0 * * * * *', window: 20 } |
| 60 | // DeployEth.s.sol's keyless demo holders: keccak256("upgrade-airlock/demo-holder/<i>"), identical on every deployment. |
| 61 | const DEMO_HOLDERS = [ |
| 62 | '0x50D70Ff5338289b11E91A0fF5336DB29BDd510e0', |
| 63 | '0x01B22bF1E81f3563727E0F7BD6c2415B87136Da5', |
| 64 | '0x22421cA36b30D2D4Db5c32fEd40505530D52Ea22', |
| 65 | ] |
| 66 | |
| 67 | const isAddr = (a) => typeof a === 'string' && /^0x[0-9a-fA-F]{40}$/.test(a) |
| 68 | const isUrl = (u) => /^https?:\/\/[^\s]+$/.test(u) |
| 69 | |
| 70 | function 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 | |
| 97 | function 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 | |
| 117 | function 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 | |
| 128 | async 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 | |
| 141 | const show = (p) => (isAbsolute(p) && !relative(ROOT, p).startsWith('..') ? relative(ROOT, p) : p) |
| 142 | |
| 143 | const o = parseArgs(process.argv.slice(2)) |
| 144 | let cfg |
| 145 | let out |
| 146 | if (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 | } |
| 188 | check(cfg) |
| 189 | const how = await schemaCheck(cfg) |
| 190 | mkdirSync(dirname(out), { recursive: true }) |
| 191 | writeFileSync(out, `${JSON.stringify(cfg, null, 2)}\n`) |
| 192 | console.log(`wrote ${show(out)} (${how})`) |
| 193 | console.log(` airlockAdmin ${cfg.airlockAdmin} receiver ${cfg.receiver} proxy ${cfg.proxy}`) |
| 194 | console.log(` providers ${cfg.rpcs.map((r) => `${r.label}=${r.url}`).join(' ')} quorum ${cfg.minAgree}-of-${cfg.rpcs.length}`) |
| 195 | console.log(` sweeper "${cfg.sweepSchedule}" over the last ${cfg.sweepWindow} proposal ids`) |