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. |
| 17 | import { secp256k1 } from '@noble/curves/secp256k1' |
| 18 | import { |
| 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' |
| 30 | import { ACTION, ZERO_HASH } from './abi' |
| 31 | import type { Verdict } from './judge' |
| 32 | import { type RuleId, ruleHash } from './rules' |
| 33 | |
| 34 | export type Action = (typeof ACTION)[keyof typeof ACTION] |
| 35 | |
| 36 | export 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). */ |
| 46 | export 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 | |
| 58 | export const VERDICT_TYPE = |
| 59 | 'Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)' |
| 60 | |
| 61 | export 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). */ |
| 74 | export 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). */ |
| 83 | export 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. */ |
| 96 | export 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 | |
| 104 | export const REPORT_PARAMS = parseAbiParameters( |
| 105 | 'uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash, bytes32 evidenceHash, uint64 validUntilBlock, bytes signature', |
| 106 | ) |
| 107 | |
| 108 | export 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 | |
| 113 | export 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 | |
| 118 | export 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). */ |
| 133 | export 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 | */ |
| 140 | export 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 | |
| 159 | export const evidenceHashOf = (canonical: string): Hex => keccak256(stringToBytes(canonical)) |
| 160 | |
| 161 | /** JSON with object keys sorted at every level (arrays keep their order). */ |
| 162 | export 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 | } |