cre/veto/tools/check-evidence.ts73 lines · 3508 bytes
| 1 | // Recomputes every AirlockReceiver verdict's evidenceHash from chain data alone: AirlockAdmin.getProposal(id) plus |
| 2 | // the action and rule in the receiver's Verdict event. Prints one line per verdict; exits 1 on any mismatch. |
| 3 | // |
| 4 | // bun tools/check-evidence.ts <rpc-url> <deployment.json> |
| 5 | // |
| 6 | // The deployment JSON is the one script/DeployEth.s.sol writes (chainId, startBlock, airlockAdmin, receiver). |
| 7 | // The Verdict logs are read from startBlock in windows of 10,000 blocks, each tried up to 3 times. Some public RPCs |
| 8 | // refuse old logs (publicnode answers some requests with 4444 "pruned history unavailable"); on live Sepolia pass one |
| 9 | // that keeps the history, e.g. https://sepolia.gateway.tenderly.co. |
| 10 | import { type Address, createPublicClient, type Hex, http } from 'viem' |
| 11 | import { ACTION, AIRLOCK_ADMIN_ABI, RECEIVER_ABI } from '../abi' |
| 12 | import { canonicalEvidence, evidenceHashOf, proposalDigest } from '../report' |
| 13 | import { RULE_IDS, type RuleId, ruleHash } from '../rules' |
| 14 | |
| 15 | const [rpc, file] = process.argv.slice(2) |
| 16 | if (!rpc || !file) { |
| 17 | console.error('usage: bun tools/check-evidence.ts <rpc-url> <deployment.json>') |
| 18 | process.exit(2) |
| 19 | } |
| 20 | const d = (await Bun.file(file).json()) as { chainId: number; startBlock: number; airlockAdmin: Address; receiver: Address } |
| 21 | const client = createPublicClient({ transport: http(rpc) }) |
| 22 | const byHash = new Map(RULE_IDS.map((r) => [ruleHash(r), r])) |
| 23 | |
| 24 | const WINDOW = 10_000n |
| 25 | const TRIES = 3 |
| 26 | const verdictLogs = (fromBlock: bigint, toBlock: bigint) => |
| 27 | client.getContractEvents({ address: d.receiver, abi: RECEIVER_ABI, eventName: 'Verdict', fromBlock, toBlock }) |
| 28 | const logs: Awaited<ReturnType<typeof verdictLogs>> = [] |
| 29 | const head = await client.getBlockNumber() |
| 30 | for (let lo = BigInt(d.startBlock); lo <= head; lo += WINDOW) { |
| 31 | const hi = lo + WINDOW - 1n < head ? lo + WINDOW - 1n : head |
| 32 | for (let attempt = 1; ; attempt++) { |
| 33 | try { |
| 34 | logs.push(...(await verdictLogs(lo, hi))) |
| 35 | break |
| 36 | } catch (e) { |
| 37 | if (attempt >= TRIES) { |
| 38 | console.error(`blocks ${lo}-${hi}: the RPC refused the Verdict logs ${TRIES} times (pass an RPC that keeps the history)`) |
| 39 | throw e |
| 40 | } |
| 41 | await Bun.sleep(2000) |
| 42 | } |
| 43 | } |
| 44 | } |
| 45 | let bad = 0 |
| 46 | for (const log of logs) { |
| 47 | const { proposalId, action, ruleHash: rh, evidenceHash } = log.args as { proposalId: bigint; action: number; ruleHash: Hex; evidenceHash: Hex } |
| 48 | const g = await client.readContract({ address: d.airlockAdmin, abi: AIRLOCK_ADMIN_ABI, functionName: 'getProposal', args: [proposalId] }) |
| 49 | const proposal = { |
| 50 | id: proposalId, |
| 51 | kind: Number(g.kind), |
| 52 | proposer: g.proposer, |
| 53 | target: g.target, |
| 54 | value: g.value, |
| 55 | codehash: g.codehash, |
| 56 | dataHash: g.dataHash, |
| 57 | proposedBlock: BigInt(g.proposedBlock), |
| 58 | readyBlock: BigInt(g.readyBlock), |
| 59 | } |
| 60 | const rule: RuleId | null = action === ACTION.Clear ? null : (byHash.get(rh) ?? null) |
| 61 | const evidence = canonicalEvidence({ |
| 62 | chainId: BigInt(d.chainId), |
| 63 | airlockAdmin: d.airlockAdmin, |
| 64 | proposal, |
| 65 | proposalDigest: proposalDigest(proposal), |
| 66 | verdict: { verdict: action === ACTION.Clear ? 'pass' : 'veto', rule }, |
| 67 | }) |
| 68 | const ok = evidenceHashOf(evidence) === evidenceHash.toLowerCase() |
| 69 | if (!ok) bad++ |
| 70 | console.log(`${ok ? 'ok ' : 'BAD '} #${proposalId} ${action === ACTION.Clear ? 'Clear' : `Veto ${rule ?? rh}`} evidence ${evidenceHash}${ok ? ' recomputed from getProposal' : ` != ${evidenceHashOf(evidence)}`}`) |
| 71 | } |
| 72 | console.log(`${logs.length} verdict(s), ${bad} mismatch(es)`) |
| 73 | process.exit(bad ? 1 : 0) |