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 / cre / veto / report.ts
cre/veto/report.ts171 lines · 7514 bytes
1// Verdict report + attestation + evidence. Byte-for-byte contract with src/cre/AirlockReceiver.sol and
2// src/AirlockAdmin.sol:
3//
4// report = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash,
5// bytes32 evidenceHash, uint64 validUntilBlock, bytes signature)
6// action 0 = Veto (ruleHash = the broken rule), 1 = Clear (ruleHash = 0)
7// signature = 65 bytes r||s||v (v in {27,28}, low-s) by the attester over the EIP-712 digest of
8// Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,
9// bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)
10// domain { name: "AirlockReceiver", version: "1", chainId, verifyingContract: receiver }
11// proposalDigest = keccak256(abi.encode(uint8 kind, address target, uint256 value, bytes32 codehash,
12// bytes32 dataHash, uint64 proposedBlock)) (AirlockAdmin.proposalDigest)
13// evidenceHash = keccak256(utf8(canonical evidence JSON)): the proposal, its digest, the verdict and the rule,
14// with sorted keys, so anyone can recompute it from chain data alone.
15//
16// Pure JS (viem + @noble/curves): runs unchanged in Bun tests and in the CRE WASM (QuickJS) runtime.
17import { secp256k1 } from '@noble/curves/secp256k1'
18import {
19 type Address,
20 bytesToHex,
21 decodeAbiParameters,
22 encodeAbiParameters,
23 type Hex,
24 hashTypedData,
25 hexToBytes,
26 keccak256,
27 parseAbiParameters,
28 stringToBytes,
29} from 'viem'
30import { ACTION, ZERO_HASH } from './abi'
31import type { Verdict } from './judge'
32import { type RuleId, ruleHash } from './rules'
33
34export type Action = (typeof ACTION)[keyof typeof ACTION]
35
36export type VerdictBody = {
37 action: Action
38 proposalId: bigint
39 proposalDigest: Hex
40 ruleHash: Hex
41 evidenceHash: Hex
42 validUntilBlock: bigint
43}
44
45/** The on-chain proposal fields a verdict is about (AirlockAdmin.getProposal minus the mutable status/vetoCount). */
46export type ProposalFields = {
47 id: bigint
48 kind: number
49 proposer: Address
50 target: Address
51 value: bigint
52 codehash: Hex
53 dataHash: Hex
54 proposedBlock: bigint
55 readyBlock: bigint
56}
57
58export const VERDICT_TYPE =
59 'Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)'
60
61export const VERDICT_TYPES = {
62 Verdict: [
63 { name: 'airlockAdmin', type: 'address' },
64 { name: 'action', type: 'uint8' },
65 { name: 'proposalId', type: 'uint256' },
66 { name: 'proposalDigest', type: 'bytes32' },
67 { name: 'ruleHash', type: 'bytes32' },
68 { name: 'evidenceHash', type: 'bytes32' },
69 { name: 'validUntilBlock', type: 'uint64' },
70 ],
71} as const
72
73/** Equals AirlockReceiver.verdictDigest(action, proposalId, proposalDigest, ruleHash, evidenceHash, validUntilBlock). */
74export const verdictDigest = (chainId: bigint, receiver: Address, airlockAdmin: Address, b: VerdictBody): Hex =>
75 hashTypedData({
76 domain: { name: 'AirlockReceiver', version: '1', chainId, verifyingContract: receiver },
77 types: VERDICT_TYPES,
78 primaryType: 'Verdict',
79 message: { airlockAdmin, ...b },
80 })
81
82/** Equals AirlockAdmin.proposalDigest(id). */
83export const proposalDigest = (p: Pick<ProposalFields, 'kind' | 'target' | 'value' | 'codehash' | 'dataHash' | 'proposedBlock'>): Hex =>
84 keccak256(
85 encodeAbiParameters(parseAbiParameters('uint8 kind, address target, uint256 value, bytes32 codehash, bytes32 dataHash, uint64 proposedBlock'), [
86 p.kind,
87 p.target,
88 p.value,
89 p.codehash,
90 p.dataHash,
91 p.proposedBlock,
92 ]),
93 )
94
95/** Deterministic (RFC 6979) low-s secp256k1 signature, r||s||v with v = 27/28. Synchronous: QuickJS-safe. */
96export const signDigest = (privateKey: Hex, digest: Hex): Hex => {
97 const sig = secp256k1.sign(hexToBytes(digest), hexToBytes(privateKey), { lowS: true })
98 const out = new Uint8Array(65)
99 out.set(sig.toCompactRawBytes(), 0)
100 out[64] = 27 + sig.recovery
101 return bytesToHex(out)
102}
103
104export const REPORT_PARAMS = parseAbiParameters(
105 'uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash, bytes32 evidenceHash, uint64 validUntilBlock, bytes signature',
106)
107
108export const encodeReport = (b: VerdictBody, signature: Hex): Hex => {
109 if ((signature.length - 2) / 2 !== 65) throw new Error('signature must be 65 bytes')
110 return encodeAbiParameters(REPORT_PARAMS, [b.action, b.proposalId, b.proposalDigest, b.ruleHash, b.evidenceHash, b.validUntilBlock, signature])
111}
112
113export const decodeReport = (payload: Hex): VerdictBody & { signature: Hex } => {
114 const [action, proposalId, digest, rule, evidenceHash, validUntilBlock, signature] = decodeAbiParameters(REPORT_PARAMS, payload)
115 return { action: action as Action, proposalId, proposalDigest: digest, ruleHash: rule, evidenceHash, validUntilBlock, signature }
116}
117
118export const buildSignedReport = (
119 attesterKey: Hex,
120 chainId: bigint,
121 receiver: Address,
122 airlockAdmin: Address,
123 b: VerdictBody,
124): { digest: Hex; signature: Hex; payload: Hex } => {
125 if (b.action === ACTION.Clear && b.ruleHash !== ZERO_HASH) throw new Error('a Clear carries no rule')
126 if (b.action === ACTION.Veto && b.ruleHash === ZERO_HASH) throw new Error('a Veto needs a rule')
127 const digest = verdictDigest(chainId, receiver, airlockAdmin, b)
128 const signature = signDigest(attesterKey, digest)
129 return { digest, signature, payload: encodeReport(b, signature) }
130}
131
132/** Report action and ruleHash for a verdict: pass -> Clear (no rule), veto -> Veto (the broken rule). */
133export const actionOf = (v: Verdict): { action: Action; ruleHash: Hex } =>
134 v.verdict === 'pass' ? { action: ACTION.Clear, ruleHash: ZERO_HASH } : { action: ACTION.Veto, ruleHash: ruleHash(v.rule as RuleId) }
135
136/**
137 * Canonical evidence JSON: the proposal fields, its digest and the DON-agreed verdict, with sorted keys. Everything
138 * in it is on-chain (getProposal) or in the report, so anyone can recompute the hash the receiver records.
139 */
140export const canonicalEvidence = (e: { chainId: bigint; airlockAdmin: Address; proposal: ProposalFields; proposalDigest: Hex; verdict: Verdict }): string =>
141 stableStringify({
142 schema: 'upgrade-airlock/evidence-v2',
143 chainId: e.chainId.toString(),
144 airlockAdmin: e.airlockAdmin.toLowerCase(),
145 proposalId: e.proposal.id.toString(),
146 kind: e.proposal.kind,
147 proposer: e.proposal.proposer.toLowerCase(),
148 target: e.proposal.target.toLowerCase(),
149 value: e.proposal.value.toString(),
150 codehash: e.proposal.codehash.toLowerCase(),
151 dataHash: e.proposal.dataHash.toLowerCase(),
152 proposedBlock: e.proposal.proposedBlock.toString(),
153 readyBlock: e.proposal.readyBlock.toString(),
154 proposalDigest: e.proposalDigest.toLowerCase(),
155 verdict: e.verdict.verdict,
156 rule: e.verdict.rule,
157 })
158
159export const evidenceHashOf = (canonical: string): Hex => keccak256(stringToBytes(canonical))
160
161/** JSON with object keys sorted at every level (arrays keep their order). */
162export function stableStringify(v: unknown): string {
163 if (v === null || typeof v !== 'object') return JSON.stringify(v)
164 if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`
165 const o = v as Record<string, unknown>
166 return `{${Object.keys(o)
167 .filter((k) => o[k] !== undefined)
168 .sort()
169 .map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`)
170 .join(',')}}`
171}