scripts/sepolia/gas-replay.ts148 lines · 9688 bytes
| 1 | #!/usr/bin/env bun |
| 2 | // Gas pre-flight on live Sepolia, read-only: replays a fork rehearsal's transactions (the deployment, the funding, |
| 3 | // the proposals, the CRE Clear and Veto reports, manual clearances, the execute) on live Ethereum Sepolia with one |
| 4 | // eth_simulateV1 call, with the same senders, calldata, gas limits and block numbers, so every transaction runs |
| 5 | // against live state under the rules Sepolia actually runs. Nothing is sent. |
| 6 | // |
| 7 | // FORK=19631 bun scripts/sepolia/gas-replay.ts [--live-rpc url] [--json file] [--deployment file] |
| 8 | // |
| 9 | // Needs the rehearsal's fork still running (the transactions are read from it). The replay starts from the live |
| 10 | // block the fork was taken at (anvil_nodeInfo) and keeps the fork's block numbers, so the contracts land on the same |
| 11 | // addresses and every report's proposal digest (it binds the proposal block) and validUntilBlock still match. |
| 12 | // The provider must still serve state at the fork block: run it within a few hours of starting the fork. |
| 13 | // Checks: every transaction succeeds; every report really lands (the receiver's Verdict log plus AirlockAdmin's |
| 14 | // Cleared or Vetoed log; the forwarder swallows a reverting receiver) within the gas limit the workflow gives |
| 15 | // writeReport; live gas equals fork gas. |
| 16 | // The default provider is drpc: publicnode caps the gas one eth_simulateV1 request may burn (about 50M), which the |
| 17 | // 87M-gas deployment exceeds ("intrinsic gas too low"); the workflow's own simulations burn far less. |
| 18 | import { existsSync, readFileSync, writeFileSync } from 'node:fs' |
| 19 | import { join } from 'node:path' |
| 20 | |
| 21 | const args = process.argv.slice(2) |
| 22 | const arg = (k: string) => { |
| 23 | const i = args.indexOf(`--${k}`) |
| 24 | return i >= 0 ? args[i + 1] : undefined |
| 25 | } |
| 26 | const fork = process.env.FORK |
| 27 | if (!fork) throw new Error('set FORK=<port> (the fork that ran the rehearsal)') |
| 28 | const forkRpc = `http://127.0.0.1:${fork}` |
| 29 | const liveRpc = arg('live-rpc') ?? 'https://sepolia.drpc.org' |
| 30 | const state = join((process.env.TMPDIR ?? '/tmp').replace(/\/$/, ''), `upgrade-airlock-fork-${fork}`) |
| 31 | const depPath = arg('deployment') ?? join(state, 'deployment.json') |
| 32 | if (!existsSync(depPath)) throw new Error(`no deployment at ${depPath}`) |
| 33 | const d = JSON.parse(readFileSync(depPath, 'utf8')) |
| 34 | if (!d.airlockAdmin) throw new Error(`${depPath} has no airlockAdmin, so it is not a deployment of this stack`) |
| 35 | const lc = (a?: string | null) => (a ?? '').toLowerCase() |
| 36 | |
| 37 | async function rpc(url: string, method: string, params: unknown[]): Promise<any> { |
| 38 | const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }) |
| 39 | const j = (await r.json()) as any |
| 40 | if (j.error) throw new Error(`${method}: ${j.error.message}`) |
| 41 | return j.result |
| 42 | } |
| 43 | const hex = (n: number | bigint) => `0x${n.toString(16)}` |
| 44 | const num = (h: string) => Number(BigInt(h)) |
| 45 | |
| 46 | // Names for addresses and selectors in the report. |
| 47 | const names = new Map<string, string>() |
| 48 | for (const [k, v] of Object.entries(d)) if (typeof v === 'string' && /^0x[0-9a-fA-F]{40}$/.test(v) && !names.has(lc(v))) names.set(lc(v), k) |
| 49 | const SEL: Record<string, string> = {} |
| 50 | const { keccak256, toBytes } = await import(Bun.resolveSync('viem', join(import.meta.dir, '../../cre/veto'))) |
| 51 | const topic = (s: string) => keccak256(toBytes(s)) as string |
| 52 | const VETOED = topic('Vetoed(uint256,address,bytes32,uint256)') |
| 53 | const CLEARED = topic('Cleared(uint256,address,bytes32)') |
| 54 | const VERDICT = topic('Verdict(uint256,uint8,bytes32,bytes32,bytes32)') |
| 55 | for (const s of [ |
| 56 | 'proposeUpgrade(address,bytes)', |
| 57 | 'proposeSetGuardian(address,bool)', |
| 58 | 'execute(uint256)', |
| 59 | 'clear(uint256,bytes32)', |
| 60 | 'veto(uint256,bytes32)', |
| 61 | 'report(address,bytes,bytes,bytes[])', |
| 62 | 'transferOwnership(address)', |
| 63 | ]) |
| 64 | SEL[keccak256(toBytes(s)).slice(0, 10)] = s.split('(')[0] |
| 65 | |
| 66 | // 1) The rehearsal's transactions on the fork: what the owner, the approver and the emergency guardian sent, and |
| 67 | // every report sent to the forwarder (by the sender or the sweeper key). |
| 68 | const info = await rpc(forkRpc, 'anvil_nodeInfo', []).catch(() => null) |
| 69 | const forkBlock = Number(info?.forkConfig?.forkBlockNumber ?? Number(d.startBlock) - 1) |
| 70 | const forkHead = num(await rpc(forkRpc, 'eth_blockNumber', [])) |
| 71 | const roleOf = (t: any): string | null => |
| 72 | lc(t.to) === lc(d.forwarder) ? 'report' : lc(t.from) === lc(d.owner) ? 'owner' : lc(t.from) === lc(d.approver) ? 'approver' : lc(t.from) === lc(d.emergencyGuardian) ? 'guard' : null |
| 73 | type Tx = { block: number; from: string; to: string | null; input: string; value: string; gas: number; forkGasUsed: number; role: string; what: string } |
| 74 | const txs: Tx[] = [] |
| 75 | for (let b = Number(d.startBlock); b <= forkHead; b++) { |
| 76 | const blk = await rpc(forkRpc, 'eth_getBlockByNumber', [hex(b), true]) |
| 77 | for (const t of blk?.transactions ?? []) { |
| 78 | const role = roleOf(t) |
| 79 | if (!role) continue |
| 80 | const rc = await rpc(forkRpc, 'eth_getTransactionReceipt', [t.hash]) |
| 81 | const what = !t.to |
| 82 | ? `create ${names.get(lc(rc.contractAddress)) ?? rc.contractAddress}` |
| 83 | : t.input === '0x' |
| 84 | ? `ETH to ${names.get(lc(t.to)) ?? t.to.slice(0, 10)}` |
| 85 | : `${names.get(lc(t.to)) ?? t.to}.${SEL[t.input.slice(0, 10)] ?? t.input.slice(0, 10)}` |
| 86 | txs.push({ block: b, from: lc(t.from), to: t.to ? lc(t.to) : null, input: t.input, value: t.value, gas: num(t.gas), forkGasUsed: num(rc.gasUsed), role, what }) |
| 87 | } |
| 88 | } |
| 89 | if (!txs.length) throw new Error('no rehearsal transactions found on the fork') |
| 90 | const firstPropose = txs.findIndex((t) => /\.propose/.test(t.what)) |
| 91 | const deployTxs = (firstPropose < 0 ? txs : txs.slice(0, firstPropose)).filter((t) => t.role === 'owner' && t.input !== '0x') |
| 92 | |
| 93 | // 2) Live: from the fork block, with the fork's own block numbers. |
| 94 | const liveHead = num(await rpc(liveRpc, 'eth_blockNumber', [])) |
| 95 | if (liveHead < forkBlock) throw new Error(`live head ${liveHead} is behind the fork block ${forkBlock}`) |
| 96 | if (txs[0].block <= forkBlock) throw new Error(`first rehearsal transaction in block ${txs[0].block}, not after the fork block ${forkBlock}`) |
| 97 | const byBlock = new Map<number, Tx[]>() |
| 98 | for (const t of txs) byBlock.set(t.block, [...(byBlock.get(t.block) ?? []), t]) |
| 99 | const BAL = hex(10n ** 21n) |
| 100 | const funded = Object.fromEntries([...new Set(txs.map((t) => t.from))].map((a) => [a, { balance: BAL }])) |
| 101 | const blockStateCalls = [...byBlock.entries()].map(([b, list], i) => ({ |
| 102 | blockOverrides: { number: hex(b) }, |
| 103 | ...(i === 0 ? { stateOverrides: funded } : {}), |
| 104 | calls: list.map((t) => ({ |
| 105 | from: t.from, |
| 106 | ...(t.to ? { to: t.to } : {}), |
| 107 | input: t.input, |
| 108 | value: t.value, |
| 109 | gas: hex(t.gas), // the real transaction's gas limit (forge's estimate x1.3, cast's estimate, writeReport's gasLimit) |
| 110 | })), |
| 111 | })) |
| 112 | const t0 = performance.now() |
| 113 | const sim = await rpc(liveRpc, 'eth_simulateV1', [{ blockStateCalls }, hex(forkBlock)]) |
| 114 | const ms = Math.round(performance.now() - t0) |
| 115 | const results = sim.flatMap((blk: any) => blk.calls) |
| 116 | if (results.length !== txs.length) throw new Error(`simulate returned ${results.length} calls for ${txs.length} transactions`) |
| 117 | |
| 118 | // 3) Report. |
| 119 | let problems = 0 |
| 120 | console.log(`replayed ${txs.length} fork transactions (blocks ${txs[0].block}-${txs[txs.length - 1].block}) on live Sepolia state at the fork block ${forkBlock} (live head ${liveHead}); eth_simulateV1 ${ms} ms`) |
| 121 | console.log('\n block role transaction gas limit fork gas live gas status') |
| 122 | txs.forEach((t, i) => { |
| 123 | const r = results[i] |
| 124 | const live = num(r.gasUsed) |
| 125 | const okStatus = r.status === '0x1' |
| 126 | const notes: string[] = [] |
| 127 | if (!okStatus) notes.push(`FAILED ${r.error?.message ?? ''}`.trim()) |
| 128 | if (t.role === 'report') { |
| 129 | const logs = r.logs ?? [] |
| 130 | const verdict = logs.find((l: any) => lc(l.address) === lc(d.receiver) && l.topics[0] === VERDICT) |
| 131 | const landed = logs.find((l: any) => lc(l.address) === lc(d.airlockAdmin) && (l.topics[0] === VETOED || l.topics[0] === CLEARED)) |
| 132 | if (!verdict || !landed) notes.push('NO VERDICT (the receiver reverted inside the forwarder)') |
| 133 | else notes.push(`${landed.topics[0] === VETOED ? 'vetoed' : 'cleared'} #${num(landed.topics[1])}; ${Math.round((100 * live) / t.gas)}% of the ${t.gas} gas limit`) |
| 134 | } |
| 135 | if (live !== t.forkGasUsed) notes.push(`live differs from fork by ${live - t.forkGasUsed}`) |
| 136 | if (!okStatus || notes.some((n) => /NO VERDICT/.test(n))) problems++ |
| 137 | console.log(` ${String(t.block).padEnd(10)} ${t.role.padEnd(9)} ${t.what.padEnd(36)} ${String(t.gas).padStart(9)} ${String(t.forkGasUsed).padStart(10)} ${String(live).padStart(10)} ${okStatus ? 'ok' : 'FAIL'} ${notes.join('; ')}`) |
| 138 | }) |
| 139 | const deployLive = deployTxs.reduce((s, t) => s + num(results[txs.indexOf(t)].gasUsed), 0) |
| 140 | const deployFork = deployTxs.reduce((s, t) => s + t.forkGasUsed, 0) |
| 141 | const maxLimit = Math.max(...deployTxs.map((t) => t.gas)) |
| 142 | const gp = BigInt(await rpc(liveRpc, 'eth_gasPrice', [])) |
| 143 | const maxReport = Math.max(0, ...txs.filter((t) => t.role === 'report').map((t) => num(results[txs.indexOf(t)].gasUsed))) |
| 144 | console.log(`\ndeployment: ${deployTxs.length} transactions, ${deployLive} gas on live Sepolia (fork ${deployFork}); largest gas limit ${maxLimit}`) |
| 145 | console.log(`at the current ${gp} wei/gas: deployment ~${(Number(BigInt(deployLive) * gp) / 1e18).toFixed(12)} ETH; one report ~${(Number(BigInt(maxReport) * gp) / 1e18).toFixed(12)} ETH`) |
| 146 | if (arg('json')) writeFileSync(arg('json') as string, JSON.stringify({ forkBlock, liveHead, txs: txs.map((t, i) => ({ ...t, input: undefined, live: results[i] && { status: results[i].status, gasUsed: num(results[i].gasUsed) } })) }, null, 2)) |
| 147 | console.log(problems ? `GAS REPLAY: ${problems} problem(s)` : 'GAS REPLAY: ALL OK') |
| 148 | process.exit(problems ? 1 : 0) |