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 / web / scripts / sync-sepolia-data.mjs
web/scripts/sync-sepolia-data.mjs151 lines · 7055 bytes
1// Copies the public deployment data the war room needs into src/chain-data-sepolia.json.
2// Source: the JSON written by script/DeployEth.s.sol (OUT_JSON; keys chainId, startBlock, owner, approver,
3// emergencyGuardian, attester, forwarder, delayBlocks, holders, proxy, proxyAdmin, receiver, airlockAdmin, v1, v2,
4// evilMint, evilOwner, evilFee, evilBaseFee, evilDrain, paused).
5//
6// node scripts/sync-sepolia-data.mjs default ../deployments/sepolia.json (npm run sync:sepolia)
7// node scripts/sync-sepolia-data.mjs <deployment.json> any DeployEth output, e.g. a local fork rehearsal
8// node scripts/sync-sepolia-data.mjs --require release check: fail unless a valid deployment exists
9// node scripts/sync-sepolia-data.mjs <file> --out <file> write somewhere else (tests; vite.config.ts reuses
10// buildChainData through AIRLOCK_DEPLOYMENT)
11//
12// A missing file, or a file without an airlockAdmin key, gives a placeholder (pending: true):
13// the build still passes and the page says the contracts are not deployed yet. Only public data is copied
14// (addresses and numbers); no local path ever goes into the output.
15//
16// Without --out it also writes operator/deployment.generated.ts, the subset the operator backend needs (AirlockAdmin,
17// owner, receiver and the card implementations), as a TypeScript module: the Cloudflare Pages Functions bundle it,
18// so they never read a file at runtime. With --out (tests) nothing else is written.
19import { existsSync, readFileSync, writeFileSync } from 'node:fs'
20import { dirname, join, resolve } from 'node:path'
21import { fileURLToPath, pathToFileURL } from 'node:url'
22
23export const SEPOLIA = 11155111
24const ADDR = /^0x[0-9a-fA-F]{40}$/
25const ZERO = '0x0000000000000000000000000000000000000000'
26const IMPLS = ['v1', 'v2', 'evilMint', 'evilOwner', 'evilFee', 'evilBaseFee', 'evilDrain', 'paused']
27const ROLES = ['airlockAdmin', 'proxy', 'receiver', 'emergencyGuardian', 'approver', 'owner']
28
29export const PLACEHOLDER = Object.freeze({
30 pending: true,
31 chainId: SEPOLIA,
32 startBlock: 0,
33 delayBlocks: 25,
34 airlockAdmin: ZERO,
35 proxy: ZERO,
36 receiver: '0x0000000000000000000000000000000000000001',
37 emergencyGuardian: '0x0000000000000000000000000000000000000002',
38 approver: '0x0000000000000000000000000000000000000003',
39 owner: ZERO,
40 attester: ZERO,
41 forwarder: ZERO,
42 impls: {},
43})
44
45const isAddr = (v) => typeof v === 'string' && ADDR.test(v) && v.toLowerCase() !== ZERO
46
47/** true when the file is a DeployEth output of this contract layout (has airlockAdmin) */
48export const isAirlockDeployment = (dep) => !!dep && typeof dep === 'object' && 'airlockAdmin' in dep
49
50/** DeployEth output -> chain data; throws with the list of bad fields */
51export function buildChainData(dep) {
52 const bad = []
53 if (Number(dep.chainId) !== SEPOLIA) bad.push(`chainId=${dep.chainId} (want ${SEPOLIA})`)
54 for (const k of ROLES) if (!isAddr(dep[k])) bad.push(k)
55 if (!(Number(dep.delayBlocks) > 0)) bad.push('delayBlocks')
56 if (!(Number(dep.startBlock) >= 0)) bad.push('startBlock')
57 const lc = (k) => String(dep[k] ?? '').toLowerCase()
58 if (isAddr(dep.approver) && lc('approver') === lc('owner')) bad.push('approver == owner')
59 if (isAddr(dep.emergencyGuardian) && lc('emergencyGuardian') === lc('owner')) bad.push('emergencyGuardian == owner')
60 if (isAddr(dep.approver) && lc('approver') === lc('emergencyGuardian')) bad.push('approver == emergencyGuardian')
61 if (bad.length) throw new Error(`deployment file is missing or has bad fields: ${bad.join(', ')}`)
62 const impls = {}
63 for (const k of IMPLS) if (isAddr(dep[k])) impls[k] = dep[k]
64 return {
65 pending: false,
66 chainId: SEPOLIA,
67 startBlock: Number(dep.startBlock),
68 delayBlocks: Number(dep.delayBlocks),
69 airlockAdmin: dep.airlockAdmin,
70 proxy: dep.proxy,
71 receiver: dep.receiver,
72 emergencyGuardian: dep.emergencyGuardian,
73 approver: dep.approver,
74 owner: dep.owner,
75 attester: isAddr(dep.attester) ? dep.attester : ZERO,
76 forwarder: isAddr(dep.forwarder) ? dep.forwarder : ZERO,
77 impls,
78 }
79}
80
81const OPERATOR_IMPLS = ['v2', 'evilMint', 'evilOwner', 'evilFee', 'evilBaseFee', 'evilDrain']
82
83/** chain data -> the operator's deployment subset (operator/cards.ts: OperatorDeployment) */
84export function operatorDeployment(data) {
85 const impls = {}
86 for (const k of OPERATOR_IMPLS) if (data.impls?.[k]) impls[k] = data.impls[k]
87 return {
88 pending: !!data.pending,
89 chainId: data.chainId,
90 airlockAdmin: data.airlockAdmin,
91 owner: data.owner,
92 receiver: data.receiver,
93 impls,
94 }
95}
96
97/** The source of operator/deployment.generated.ts */
98export function operatorSource(data) {
99 return [
100 '// Generated by scripts/sync-sepolia-data.mjs from the deployment (npm run sync:sepolia, npm run build:airlock).',
101 '// Do not edit. The operator backend bundles it, so the Cloudflare Pages Functions never read a file at runtime.',
102 "import type { OperatorDeployment } from './cards.ts'",
103 '',
104 `export const DEPLOYMENT: OperatorDeployment = ${JSON.stringify(operatorDeployment(data), null, 2)}`,
105 '',
106 ].join('\n')
107}
108
109function main() {
110 const here = dirname(fileURLToPath(import.meta.url))
111 const argv = process.argv.slice(2)
112 const require = argv.includes('--require')
113 const outIdx = argv.indexOf('--out')
114 const outPath = outIdx >= 0 ? resolve(argv[outIdx + 1] ?? '') : join(here, '..', 'src', 'chain-data-sepolia.json')
115 const positional = argv.filter((a, i) => !a.startsWith('--') && !(outIdx >= 0 && i === outIdx + 1))
116 const depPath = positional[0] ? resolve(positional[0]) : join(here, '..', '..', 'deployments', 'sepolia.json')
117 const fail = (msg) => {
118 console.error(msg)
119 process.exit(1)
120 }
121
122 let out = PLACEHOLDER
123 let note = 'placeholder: no deployment file yet'
124 if (existsSync(depPath)) {
125 let dep
126 try {
127 dep = JSON.parse(readFileSync(depPath, 'utf8'))
128 } catch (e) {
129 fail(`deployment file is not JSON: ${e.message}`)
130 }
131 if (isAirlockDeployment(dep)) {
132 try {
133 out = buildChainData(dep)
134 } catch (e) {
135 fail(e.message)
136 }
137 note = `AirlockAdmin ${out.airlockAdmin}, AirlockReceiver ${out.receiver}, emergency guardian ${out.emergencyGuardian}, cooldown ${out.delayBlocks} blocks`
138 } else {
139 note = 'placeholder: the deployment file has no airlockAdmin (deploy script/DeployEth.s.sol again)'
140 }
141 }
142 if (require && out.pending) fail(`--require: ${note}`)
143 writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n')
144 console.log(`chain-data-sepolia.json: ${note}`)
145 if (outIdx < 0) {
146 writeFileSync(join(here, '..', 'operator', 'deployment.generated.ts'), operatorSource(out))
147 console.log(`operator/deployment.generated.ts: ${out.pending ? 'placeholder (operator off)' : `${Object.keys(operatorDeployment(out).impls).length} implementations + receiver`}`)
148 }
149}
150
151if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main()