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) |
| 8 | import { readFileSync, writeFileSync } from 'node:fs' |
| 9 | import { dirname, join, resolve } from 'node:path' |
| 10 | import { fileURLToPath } from 'node:url' |
| 11 | import { decodeFunctionResult, encodeFunctionData, numberToHex, parseAbi, toEventSelector } from 'viem' |
| 12 | import { buildChainData, SEPOLIA } from './sync-sepolia-data.mjs' |
| 13 | |
| 14 | // Same signatures as src/abi.ts (the selftest checks those against the forge artifacts) |
| 15 | const 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 | ]) |
| 26 | const RECEIVER_ABI = parseAbi(['event Verdict(uint256 indexed proposalId, uint8 indexed action, bytes32 ruleHash, bytes32 evidenceHash, bytes32 workflowId)']) |
| 27 | const 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 | ] |
| 31 | const COST_EVENTS = new Set(['Vetoed', 'Cleared', 'Executed'].map((n) => toEventSelector(ADMIN_ABI.find((x) => x.type === 'event' && x.name === n)))) |
| 32 | |
| 33 | const argv = process.argv.slice(2) |
| 34 | const arg = (name) => { |
| 35 | const i = argv.indexOf(`--${name}`) |
| 36 | return i >= 0 ? argv[i + 1] : undefined |
| 37 | } |
| 38 | const fail = (m) => { |
| 39 | console.error(m) |
| 40 | process.exit(1) |
| 41 | } |
| 42 | const rpcUrl = arg('rpc') |
| 43 | const depPath = arg('deployment') |
| 44 | if (!rpcUrl || !depPath) fail('usage: node scripts/record-run.mjs --rpc <url> --deployment <DeployEth output JSON> [--source sepolia|sepolia-fork] [--out file]') |
| 45 | const loopback = /^https?:\/\/(?:127\.\d+\.\d+\.\d+|localhost|\[::1\])(?::\d+)?(?:\/|$)/i.test(rpcUrl) |
| 46 | const source = arg('source') ?? (loopback ? 'sepolia-fork' : 'sepolia') |
| 47 | if (source !== 'sepolia' && source !== 'sepolia-fork') fail('--source must be sepolia or sepolia-fork') |
| 48 | const range = Number(arg('range') ?? (loopback ? 1000 : 50)) |
| 49 | const here = dirname(fileURLToPath(import.meta.url)) |
| 50 | const outPath = resolve(arg('out') ?? join(here, '..', 'src', 'recorded-run.json')) |
| 51 | |
| 52 | let id = 1 |
| 53 | async 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 | } |
| 66 | const 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 | |
| 69 | const dep = buildChainData(JSON.parse(readFileSync(depPath, 'utf8'))) |
| 70 | const chainId = parseInt(await rpc('eth_chainId', []), 16) |
| 71 | if (chainId !== SEPOLIA) fail(`chainId ${chainId}: want Sepolia (${SEPOLIA}) or a fork of it`) |
| 72 | const head = parseInt(await rpc('eth_blockNumber', []), 16) |
| 73 | |
| 74 | const logs = [] |
| 75 | for (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 | |
| 80 | const next = Number(await call('nextId')) |
| 81 | const proposals = {} |
| 82 | const wantBlocks = new Set(logs.map((l) => parseInt(l.blockNumber, 16))) |
| 83 | for (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 | |
| 100 | const blocks = {} |
| 101 | for (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 | } |
| 105 | const receipts = {} |
| 106 | for (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 | |
| 117 | const out = { v: 1, source, recordedOn: new Date().toISOString().slice(0, 10), head, deployment: dep, logs, blocks, receipts, proposals } |
| 118 | writeFileSync(outPath, JSON.stringify(out, null, 1) + '\n') |
| 119 | console.log(`recorded ${Object.keys(proposals).length} proposals, ${logs.length} logs, blocks ${dep.startBlock}..${head} (${source})`) |