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 / record-run.mjs
web/scripts/record-run.mjs119 lines · 6413 bytes
1// Records a finished run for offline playback (?mode=offline) into src/recorded-run.json: every AirlockAdmin and
2// AirlockReceiver log since the deployment block, the final getProposal + clearanceOf of each proposal, the block
3// timestamps those logs need, and the receipts of verdict and execute transactions. Read only (eth_* reads).
4//
5// node scripts/record-run.mjs --rpc http://127.0.0.1:19621 --deployment <DeployEth output JSON>
6// options: --source sepolia|sepolia-fork (default: sepolia-fork for a loopback RPC, else sepolia)
7// --out <file> (default src/recorded-run.json) --range <blocks per eth_getLogs> (default 1000 local, 50 remote)
8import { readFileSync, writeFileSync } from 'node:fs'
9import { dirname, join, resolve } from 'node:path'
10import { fileURLToPath } from 'node:url'
11import { decodeFunctionResult, encodeFunctionData, numberToHex, parseAbi, toEventSelector } from 'viem'
12import { buildChainData, SEPOLIA } from './sync-sepolia-data.mjs'
13
14// Same signatures as src/abi.ts (the selftest checks those against the forge artifacts)
15const ADMIN_ABI = parseAbi([
16 'event Proposed(uint256 indexed id, uint8 indexed kind, address indexed target, uint256 value, bytes data, bytes32 codehash, uint64 readyBlock)',
17 'event Vetoed(uint256 indexed id, address indexed guardian, bytes32 ruleHash, uint256 vetoCount)',
18 'event Cleared(uint256 indexed id, address indexed clearer, bytes32 evidenceHash)',
19 'event Executed(uint256 indexed id, address executor)',
20 'event ReasonAttached(uint256 indexed id, address guardian, bytes32 reportHash)',
21 'struct Proposal { uint8 kind; uint8 status; address proposer; address target; uint256 value; bytes32 codehash; bytes32 dataHash; uint64 proposedBlock; uint64 readyBlock; uint256 vetoCount; }',
22 'function getProposal(uint256 id) view returns (Proposal)',
23 'function clearanceOf(uint256 id) view returns (bytes32)',
24 'function nextId() view returns (uint256)',
25])
26const RECEIVER_ABI = parseAbi(['event Verdict(uint256 indexed proposalId, uint8 indexed action, bytes32 ruleHash, bytes32 evidenceHash, bytes32 workflowId)'])
27const TOPICS = [
28 ...['Proposed', 'Vetoed', 'Cleared', 'Executed', 'ReasonAttached'].map((n) => toEventSelector(ADMIN_ABI.find((x) => x.type === 'event' && x.name === n))),
29 toEventSelector(RECEIVER_ABI[0]),
30]
31const COST_EVENTS = new Set(['Vetoed', 'Cleared', 'Executed'].map((n) => toEventSelector(ADMIN_ABI.find((x) => x.type === 'event' && x.name === n))))
32
33const argv = process.argv.slice(2)
34const arg = (name) => {
35 const i = argv.indexOf(`--${name}`)
36 return i >= 0 ? argv[i + 1] : undefined
37}
38const fail = (m) => {
39 console.error(m)
40 process.exit(1)
41}
42const rpcUrl = arg('rpc')
43const depPath = arg('deployment')
44if (!rpcUrl || !depPath) fail('usage: node scripts/record-run.mjs --rpc <url> --deployment <DeployEth output JSON> [--source sepolia|sepolia-fork] [--out file]')
45const loopback = /^https?:\/\/(?:127\.\d+\.\d+\.\d+|localhost|\[::1\])(?::\d+)?(?:\/|$)/i.test(rpcUrl)
46const source = arg('source') ?? (loopback ? 'sepolia-fork' : 'sepolia')
47if (source !== 'sepolia' && source !== 'sepolia-fork') fail('--source must be sepolia or sepolia-fork')
48const range = Number(arg('range') ?? (loopback ? 1000 : 50))
49const here = dirname(fileURLToPath(import.meta.url))
50const outPath = resolve(arg('out') ?? join(here, '..', 'src', 'recorded-run.json'))
51
52let id = 1
53async function rpc(method, params) {
54 for (let i = 0; ; i++) {
55 try {
56 const r = await fetch(rpcUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: id++, method, params }) })
57 const j = await r.json()
58 if (j.error) throw new Error(j.error.message)
59 return j.result
60 } catch (e) {
61 if (i >= 3) throw e
62 await new Promise((r) => setTimeout(r, 500 * (i + 1)))
63 }
64 }
65}
66const call = async (functionName, args = []) =>
67 decodeFunctionResult({ abi: ADMIN_ABI, functionName, data: await rpc('eth_call', [{ to: dep.airlockAdmin, data: encodeFunctionData({ abi: ADMIN_ABI, functionName, args }) }, 'latest']) })
68
69const dep = buildChainData(JSON.parse(readFileSync(depPath, 'utf8')))
70const chainId = parseInt(await rpc('eth_chainId', []), 16)
71if (chainId !== SEPOLIA) fail(`chainId ${chainId}: want Sepolia (${SEPOLIA}) or a fork of it`)
72const head = parseInt(await rpc('eth_blockNumber', []), 16)
73
74const logs = []
75for (let a = dep.startBlock; a <= head; a += range) {
76 const got = await rpc('eth_getLogs', [{ address: [dep.airlockAdmin, dep.receiver], fromBlock: numberToHex(a), toBlock: numberToHex(Math.min(head, a + range - 1)), topics: [TOPICS] }])
77 for (const l of got) logs.push({ address: l.address, topics: l.topics, data: l.data, blockNumber: l.blockNumber, transactionHash: l.transactionHash, logIndex: l.logIndex })
78}
79
80const next = Number(await call('nextId'))
81const proposals = {}
82const wantBlocks = new Set(logs.map((l) => parseInt(l.blockNumber, 16)))
83for (let i = 1; i < next; i++) {
84 const p = await call('getProposal', [BigInt(i)])
85 const clearanceOf = await call('clearanceOf', [BigInt(i)])
86 proposals[i] = {
87 kind: Number(p.kind),
88 status: Number(p.status),
89 proposer: p.proposer,
90 target: p.target,
91 codehash: p.codehash,
92 proposedBlock: Number(p.proposedBlock),
93 readyBlock: Number(p.readyBlock),
94 vetoCount: Number(p.vetoCount),
95 clearanceOf,
96 }
97 wantBlocks.add(Number(p.proposedBlock))
98}
99
100const blocks = {}
101for (const n of [...wantBlocks].sort((a, b) => a - b)) {
102 const b = await rpc('eth_getBlockByNumber', [numberToHex(n), false])
103 if (b?.timestamp) blocks[n] = parseInt(b.timestamp, 16)
104}
105const receipts = {}
106for (const tx of new Set(logs.filter((l) => COST_EVENTS.has(l.topics[0])).map((l) => l.transactionHash))) {
107 const r = await rpc('eth_getTransactionReceipt', [tx])
108 if (r)
109 receipts[tx] = {
110 status: r.status === '0x1' ? 'success' : 'reverted',
111 gasUsed: BigInt(r.gasUsed).toString(),
112 effectiveGasPrice: BigInt(r.effectiveGasPrice ?? '0x0').toString(),
113 blockNumber: parseInt(r.blockNumber, 16),
114 }
115}
116
117const out = { v: 1, source, recordedOn: new Date().toISOString().slice(0, 10), head, deployment: dep, logs, blocks, receipts, proposals }
118writeFileSync(outPath, JSON.stringify(out, null, 1) + '\n')
119console.log(`recorded ${Object.keys(proposals).length} proposals, ${logs.length} logs, blocks ${dep.startBlock}..${head} (${source})`)