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 / scripts / sepolia / gas-replay.ts
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.
18import { existsSync, readFileSync, writeFileSync } from 'node:fs'
19import { join } from 'node:path'
20
21const args = process.argv.slice(2)
22const arg = (k: string) => {
23 const i = args.indexOf(`--${k}`)
24 return i >= 0 ? args[i + 1] : undefined
25}
26const fork = process.env.FORK
27if (!fork) throw new Error('set FORK=<port> (the fork that ran the rehearsal)')
28const forkRpc = `http://127.0.0.1:${fork}`
29const liveRpc = arg('live-rpc') ?? 'https://sepolia.drpc.org'
30const state = join((process.env.TMPDIR ?? '/tmp').replace(/\/$/, ''), `upgrade-airlock-fork-${fork}`)
31const depPath = arg('deployment') ?? join(state, 'deployment.json')
32if (!existsSync(depPath)) throw new Error(`no deployment at ${depPath}`)
33const d = JSON.parse(readFileSync(depPath, 'utf8'))
34if (!d.airlockAdmin) throw new Error(`${depPath} has no airlockAdmin, so it is not a deployment of this stack`)
35const lc = (a?: string | null) => (a ?? '').toLowerCase()
36
37async 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}
43const hex = (n: number | bigint) => `0x${n.toString(16)}`
44const num = (h: string) => Number(BigInt(h))
45
46// Names for addresses and selectors in the report.
47const names = new Map<string, string>()
48for (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)
49const SEL: Record<string, string> = {}
50const { keccak256, toBytes } = await import(Bun.resolveSync('viem', join(import.meta.dir, '../../cre/veto')))
51const topic = (s: string) => keccak256(toBytes(s)) as string
52const VETOED = topic('Vetoed(uint256,address,bytes32,uint256)')
53const CLEARED = topic('Cleared(uint256,address,bytes32)')
54const VERDICT = topic('Verdict(uint256,uint8,bytes32,bytes32,bytes32)')
55for (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).
68const info = await rpc(forkRpc, 'anvil_nodeInfo', []).catch(() => null)
69const forkBlock = Number(info?.forkConfig?.forkBlockNumber ?? Number(d.startBlock) - 1)
70const forkHead = num(await rpc(forkRpc, 'eth_blockNumber', []))
71const 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
73type Tx = { block: number; from: string; to: string | null; input: string; value: string; gas: number; forkGasUsed: number; role: string; what: string }
74const txs: Tx[] = []
75for (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}
89if (!txs.length) throw new Error('no rehearsal transactions found on the fork')
90const firstPropose = txs.findIndex((t) => /\.propose/.test(t.what))
91const 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.
94const liveHead = num(await rpc(liveRpc, 'eth_blockNumber', []))
95if (liveHead < forkBlock) throw new Error(`live head ${liveHead} is behind the fork block ${forkBlock}`)
96if (txs[0].block <= forkBlock) throw new Error(`first rehearsal transaction in block ${txs[0].block}, not after the fork block ${forkBlock}`)
97const byBlock = new Map<number, Tx[]>()
98for (const t of txs) byBlock.set(t.block, [...(byBlock.get(t.block) ?? []), t])
99const BAL = hex(10n ** 21n)
100const funded = Object.fromEntries([...new Set(txs.map((t) => t.from))].map((a) => [a, { balance: BAL }]))
101const 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}))
112const t0 = performance.now()
113const sim = await rpc(liveRpc, 'eth_simulateV1', [{ blockStateCalls }, hex(forkBlock)])
114const ms = Math.round(performance.now() - t0)
115const results = sim.flatMap((blk: any) => blk.calls)
116if (results.length !== txs.length) throw new Error(`simulate returned ${results.length} calls for ${txs.length} transactions`)
117
118// 3) Report.
119let problems = 0
120console.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`)
121console.log('\n block role transaction gas limit fork gas live gas status')
122txs.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})
139const deployLive = deployTxs.reduce((s, t) => s + num(results[txs.indexOf(t)].gasUsed), 0)
140const deployFork = deployTxs.reduce((s, t) => s + t.forkGasUsed, 0)
141const maxLimit = Math.max(...deployTxs.map((t) => t.gas))
142const gp = BigInt(await rpc(liveRpc, 'eth_gasPrice', []))
143const maxReport = Math.max(0, ...txs.filter((t) => t.role === 'report').map((t) => num(results[txs.indexOf(t)].gasUsed)))
144console.log(`\ndeployment: ${deployTxs.length} transactions, ${deployLive} gas on live Sepolia (fork ${deployFork}); largest gas limit ${maxLimit}`)
145console.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`)
146if (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))
147console.log(problems ? `GAS REPLAY: ${problems} problem(s)` : 'GAS REPLAY: ALL OK')
148process.exit(problems ? 1 : 0)