cre/veto/test/unit.test.ts260 lines · 17343 bytes
| 1 | import { describe, expect, test } from 'bun:test' |
| 2 | import { type Address, encodeAbiParameters, encodeFunctionResult, type Hex, keccak256, parseAbiParameters, recoverAddress, stringToBytes } from 'viem' |
| 3 | import { privateKeyToAccount } from 'viem/accounts' |
| 4 | import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, MULTICALL3_ABI, ZERO_HASH } from '../abi' |
| 5 | import { dangerousOpcodes } from '../bytecode' |
| 6 | import { aggregate3RequestBytes, aggregate3ResponseBytes, chunkReads, decodeAggregate3, encodeAggregate3, READ_PAYLOAD_BYTES, type Read, readCost } from '../chain' |
| 7 | import { configSchema } from '../config' |
| 8 | import { judgeConfig, type ProviderOutcome, quorum, staticUpgradeCheck } from '../judge' |
| 9 | import { actionHash, upgradeDataKey } from '../keys' |
| 10 | import { actionOf, buildSignedReport, canonicalEvidence, decodeReport, evidenceHashOf, proposalDigest, stableStringify, VERDICT_TYPE, verdictDigest } from '../report' |
| 11 | import { RULE_IDS, ruleHash } from '../rules' |
| 12 | import { buildSimulation, fallbackCalldata, proposalActors } from '../simulate' |
| 13 | import { utf8ToBase64 } from '../workflow' |
| 14 | |
| 15 | const pass = (p: string): ProviderOutcome => ({ provider: p, verdict: 'pass', rule: null, checks: [], culprits: [], flaggedOps: [], probes: 10, selectors: 2, requests: 1 }) |
| 16 | const veto = (p: string, rule: 'R1_TOTAL_SUPPLY' | 'R2_OWNER'): ProviderOutcome => ({ |
| 17 | provider: p, |
| 18 | verdict: 'veto', |
| 19 | rule, |
| 20 | checks: [{ name: rule, before: 'a', after: 'b', ok: false }], |
| 21 | culprits: [], |
| 22 | flaggedOps: [], |
| 23 | probes: 10, |
| 24 | selectors: 2, |
| 25 | requests: 1, |
| 26 | }) |
| 27 | const err = (p: string): ProviderOutcome => ({ provider: p, verdict: 'error', error: 'timeout' }) |
| 28 | |
| 29 | describe('quorum (at least minAgree answers, all equal)', () => { |
| 30 | test('unanimous veto keeps the rule', () => expect(quorum([veto('a', 'R1_TOTAL_SUPPLY'), veto('b', 'R1_TOTAL_SUPPLY'), veto('c', 'R1_TOTAL_SUPPLY')], 2).rule).toBe('R1_TOTAL_SUPPLY')) |
| 31 | test('one provider down still decides', () => expect(quorum([pass('a'), err('b'), pass('c')], 2).verdict).toBe('pass')) |
| 32 | test('two of four down still decides', () => expect(quorum([pass('a'), err('b'), err('c'), pass('d')], 2).verdict).toBe('pass')) |
| 33 | test('a lone dissenting veto fails closed (R6), never a pass', () => { |
| 34 | const v = quorum([pass('a'), pass('b'), veto('c', 'R1_TOTAL_SUPPLY')], 2) |
| 35 | expect([v.verdict, v.rule]).toEqual(['veto', 'R6_SIM_FAILURE']) |
| 36 | }) |
| 37 | test('a lone dissenting pass cannot override vetoes', () => { |
| 38 | const v = quorum([veto('a', 'R1_TOTAL_SUPPLY'), veto('b', 'R1_TOTAL_SUPPLY'), pass('c')], 2) |
| 39 | expect([v.verdict, v.rule]).toEqual(['veto', 'R6_SIM_FAILURE']) |
| 40 | }) |
| 41 | test('a 2-2 split fails closed (R6)', () => { |
| 42 | const v = quorum([pass('a'), pass('b'), veto('c', 'R1_TOTAL_SUPPLY'), veto('d', 'R1_TOTAL_SUPPLY')], 2) |
| 43 | expect([v.verdict, v.rule]).toEqual(['veto', 'R6_SIM_FAILURE']) |
| 44 | }) |
| 45 | test('one answer is not enough (R6)', () => expect(quorum([pass('a'), err('b'), err('c')], 2).rule).toBe('R6_SIM_FAILURE')) |
| 46 | test('all providers down fail closed (R6)', () => expect(quorum([err('a'), err('b'), err('c')], 2).rule).toBe('R6_SIM_FAILURE')) |
| 47 | test('different veto rules fail closed (R6)', () => expect(quorum([veto('a', 'R1_TOTAL_SUPPLY'), veto('b', 'R2_OWNER'), err('c')], 2).rule).toBe('R6_SIM_FAILURE')) |
| 48 | }) |
| 49 | |
| 50 | describe('R7 config guard', () => { |
| 51 | const t = '0x1111111111111111111111111111111111111111' as Address |
| 52 | test('removing a guardian without approval is vetoed', () => expect(judgeConfig({ kind: KIND.SetGuardian, target: t, value: 0n }, null, false).rule).toBe('R7_CONFIG_GUARD')) |
| 53 | test('adding a guardian also needs approval', () => expect(judgeConfig({ kind: KIND.SetGuardian, target: t, value: 1n }, null, false).verdict).toBe('veto')) |
| 54 | test('an approved guardian change passes', () => expect(judgeConfig({ kind: KIND.SetGuardian, target: t, value: 0n }, null, true).verdict).toBe('pass')) |
| 55 | test('a longer delay is not weakening', () => expect(judgeConfig({ kind: KIND.SetDelay, target: '0x0000000000000000000000000000000000000000', value: 30n }, 25n, false).verdict).toBe('pass')) |
| 56 | test('a shorter delay needs approval', () => expect(judgeConfig({ kind: KIND.SetDelay, target: '0x0000000000000000000000000000000000000000', value: 20n }, 25n, false).rule).toBe('R7_CONFIG_GUARD')) |
| 57 | test('a new paused impl needs approval', () => expect(judgeConfig({ kind: KIND.SetPausedImpl, target: t, value: 0n }, null, false).rule).toBe('R7_CONFIG_GUARD')) |
| 58 | test('approval keys', () => { |
| 59 | expect(actionHash(KIND.SetGuardian, t, 0n)).toBe(keccak256(encodeAbiParameters(parseAbiParameters('uint8, address, uint256'), [1, t, 0n]))) |
| 60 | const data = '0x12345678' as Hex |
| 61 | expect(upgradeDataKey(keccak256('0x01'), keccak256(data))).toBe(keccak256(encodeAbiParameters(parseAbiParameters('bytes32, bytes32'), [keccak256('0x01'), keccak256(data)]))) |
| 62 | }) |
| 63 | }) |
| 64 | |
| 65 | describe('static upgrade rules', () => { |
| 66 | const base = { strict: false, codehash: keccak256('0x01'), codehashApproved: false, hasInitData: false, dataApproved: false, holder: '0x1111111111111111111111111111111111111111' as Address } |
| 67 | test('nothing static: the simulation decides', () => expect(staticUpgradeCheck(base)).toBeNull()) |
| 68 | test('strict mode needs an approved codehash (R5)', () => expect(staticUpgradeCheck({ ...base, strict: true })?.rule).toBe('R5_STRICT_CODEHASH')) |
| 69 | test('init data needs a codehash+data approval (R8)', () => expect(staticUpgradeCheck({ ...base, hasInitData: true })?.rule).toBe('R8_INIT_DATA')) |
| 70 | test('approved init data goes to the simulation', () => expect(staticUpgradeCheck({ ...base, hasInitData: true, dataApproved: true })).toBeNull()) |
| 71 | test('no funded holder fails closed (R6)', () => expect(staticUpgradeCheck({ ...base, holder: null })?.rule).toBe('R6_SIM_FAILURE')) |
| 72 | test('empty init data hash', () => expect(EMPTY_DATA_HASH).toBe('0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470')) |
| 73 | }) |
| 74 | |
| 75 | describe('R9 opcodes', () => { |
| 76 | test('block and fee context opcodes are flagged', () => { |
| 77 | // BASEFEE GASPRICE BLOBBASEFEE BLOBHASH GASLIMIT STOP |
| 78 | expect(dangerousOpcodes('0x483a4a494500')).toEqual(['BASEFEE', 'BLOBBASEFEE', 'BLOBHASH', 'GASLIMIT', 'GASPRICE']) |
| 79 | }) |
| 80 | test('PUSH data is not an opcode', () => expect(dangerousOpcodes('0x6048604560480000')).toEqual([])) |
| 81 | test('a trailing length that is not an exact solc CBOR map is not skipped (the old fake tail is now scanned)', () => { |
| 82 | // STOP, then 0xa2 0x48 0x49 0x44 and a "length" 0x0004: not a solc map, so BASEFEE BLOBHASH PREVRANDAO count |
| 83 | expect(dangerousOpcodes('0x00a24849440004')).toEqual(['BASEFEE', 'BLOBHASH', 'PREVRANDAO']) |
| 84 | }) |
| 85 | }) |
| 86 | |
| 87 | describe('simulation layout', () => { |
| 88 | test('every configured holder is watched', () => { |
| 89 | const holders = ['0x00000000000000000000000000000000000000a1', '0x00000000000000000000000000000000000000a2', '0x00000000000000000000000000000000000000a3'] as Address[] |
| 90 | const { layout } = buildSimulation({ |
| 91 | proxy: '0x00000000000000000000000000000000000000b1', |
| 92 | newImpl: '0x00000000000000000000000000000000000000b2', |
| 93 | newCode: '0x00', |
| 94 | holder: holders[1], |
| 95 | amount: 1n, |
| 96 | holders, |
| 97 | watch: ['0x00000000000000000000000000000000000000c1'], |
| 98 | stranger: '0x00000000000000000000000000000000000000d1', |
| 99 | receiver: '0x00000000000000000000000000000000000000d2', |
| 100 | block: 1n, |
| 101 | callGas: 1, |
| 102 | readGas: 1, |
| 103 | maxCalls: 150, |
| 104 | maxRequests: 2, |
| 105 | }) |
| 106 | for (const h of holders) expect(layout.watchList).toContain(h) |
| 107 | expect(layout.watchList).toContain('0x00000000000000000000000000000000000000c1') |
| 108 | expect(layout.watchList).toContain('0x00000000000000000000000000000000000000d2') |
| 109 | }) |
| 110 | }) |
| 111 | |
| 112 | describe('report, digest and attestation', () => { |
| 113 | const key = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex // anvil #1, public |
| 114 | const receiver = '0x81e8CcAfe8a0C69b17EBdF6a767423Be99D92544' as Address |
| 115 | const admin = '0x3d8559f13a4392E8e6f58A6c7143256193cbDEEa' as Address |
| 116 | const body = { action: ACTION.Veto, proposalId: 7n, proposalDigest: keccak256('0x07'), ruleHash: ruleHash('R1_TOTAL_SUPPLY'), evidenceHash: keccak256('0x01'), validUntilBlock: 123n } |
| 117 | |
| 118 | test('typehash matches AirlockReceiver.VERDICT_TYPEHASH', () => { |
| 119 | expect(keccak256(stringToBytes(VERDICT_TYPE))).toBe('0xf69c38ff50cd566c526626b8d004b69b911f6ebecce91e7bace3c494dd31665a') |
| 120 | }) |
| 121 | test('signature recovers to the attester and the payload round-trips', async () => { |
| 122 | const s = buildSignedReport(key, 11155111n, receiver, admin, body) |
| 123 | expect(await recoverAddress({ hash: s.digest, signature: s.signature })).toBe(privateKeyToAccount(key).address) |
| 124 | const d = decodeReport(s.payload) |
| 125 | expect([d.action, d.proposalId, d.proposalDigest, d.ruleHash, d.evidenceHash, d.validUntilBlock, d.signature]).toEqual([0, 7n, body.proposalDigest, body.ruleHash, body.evidenceHash, 123n, s.signature]) |
| 126 | }) |
| 127 | test('digest is bound to chain, receiver, airlockAdmin, action and proposal content', () => { |
| 128 | const base = verdictDigest(11155111n, receiver, admin, body) |
| 129 | expect(verdictDigest(1n, receiver, admin, body)).not.toBe(base) |
| 130 | expect(verdictDigest(11155111n, admin, admin, body)).not.toBe(base) |
| 131 | expect(verdictDigest(11155111n, receiver, receiver, body)).not.toBe(base) |
| 132 | expect(verdictDigest(11155111n, receiver, admin, { ...body, action: ACTION.Clear, ruleHash: ZERO_HASH })).not.toBe(base) |
| 133 | expect(verdictDigest(11155111n, receiver, admin, { ...body, proposalDigest: keccak256('0x08') })).not.toBe(base) |
| 134 | }) |
| 135 | test('a Clear carries no rule and a Veto needs one', () => { |
| 136 | expect(() => buildSignedReport(key, 1n, receiver, admin, { ...body, action: ACTION.Clear })).toThrow() |
| 137 | expect(() => buildSignedReport(key, 1n, receiver, admin, { ...body, ruleHash: ZERO_HASH })).toThrow() |
| 138 | expect(actionOf({ verdict: 'pass', rule: null })).toEqual({ action: ACTION.Clear, ruleHash: ZERO_HASH }) |
| 139 | expect(actionOf({ verdict: 'veto', rule: 'R7_CONFIG_GUARD' })).toEqual({ action: ACTION.Veto, ruleHash: ruleHash('R7_CONFIG_GUARD') }) |
| 140 | }) |
| 141 | test('proposalDigest = keccak256(abi.encode(kind, target, value, codehash, dataHash, proposedBlock))', () => { |
| 142 | const p = { kind: 0, target: receiver, value: 0n, codehash: keccak256('0x01'), dataHash: EMPTY_DATA_HASH, proposedBlock: 9_000_000n } |
| 143 | const manual = keccak256( |
| 144 | encodeAbiParameters(parseAbiParameters('uint8, address, uint256, bytes32, bytes32, uint64'), [0, receiver, 0n, p.codehash, p.dataHash, p.proposedBlock]), |
| 145 | ) |
| 146 | expect(proposalDigest(p)).toBe(manual) |
| 147 | expect(proposalDigest({ ...p, proposedBlock: 9_000_001n })).not.toBe(manual) |
| 148 | }) |
| 149 | test('rule hashes are keccak256("GUARD/<ID>") for all ten rules, pinned (the war room derives the same values)', () => { |
| 150 | for (const id of RULE_IDS) expect(ruleHash(id)).toBe(keccak256(stringToBytes(`GUARD/${id}`))) |
| 151 | expect(RULE_IDS.length).toBe(10) |
| 152 | expect(ruleHash('R1_TOTAL_SUPPLY')).toBe('0x2d882812e0d4af193fbd76c9ccddcbb70bac4eda3768abe4fe59cd4adb9e653f') |
| 153 | expect(ruleHash('R2_OWNER')).toBe('0x1391a3353c21683826f9e1baca10ec6ea865cbb0c2fccc79ca2576ff7b3fff5c') |
| 154 | expect(ruleHash('R9_NEW_OPCODES')).toBe('0x766e11dc2c5f99690e801f33107e733cccfe7ba712f5a72ab95d4020fbeb2b87') |
| 155 | }) |
| 156 | test('evidence is canonical and carries only proposal fields, digest, verdict and rule', () => { |
| 157 | const proposal = { id: 3n, kind: 0, proposer: admin, target: receiver, value: 0n, codehash: keccak256('0x01'), dataHash: EMPTY_DATA_HASH, proposedBlock: 10n, readyBlock: 35n } |
| 158 | const e = canonicalEvidence({ chainId: 11155111n, airlockAdmin: admin, proposal, proposalDigest: proposalDigest(proposal), verdict: { verdict: 'veto', rule: 'R9_NEW_OPCODES' } }) |
| 159 | const o = JSON.parse(e) |
| 160 | expect(Object.keys(o)).toEqual([...Object.keys(o)].sort()) |
| 161 | expect(Object.keys(o).sort()).toEqual( |
| 162 | ['airlockAdmin', 'chainId', 'codehash', 'dataHash', 'kind', 'proposalDigest', 'proposalId', 'proposedBlock', 'proposer', 'readyBlock', 'rule', 'schema', 'target', 'value', 'verdict'].sort(), |
| 163 | ) |
| 164 | expect(o.rule).toBe('R9_NEW_OPCODES') |
| 165 | expect(o.airlockAdmin).toBe(admin.toLowerCase()) |
| 166 | expect(evidenceHashOf(e)).toBe(keccak256(stringToBytes(e))) |
| 167 | }) |
| 168 | }) |
| 169 | |
| 170 | describe('batched reads', () => { |
| 171 | const getP = (id: bigint): Read => ({ to: '0x00000000000000000000000000000000000000aa', data: `0x5f5f5f5f${id.toString(16).padStart(64, '0')}` as Hex, ret: 320 }) |
| 172 | const word = (id: bigint): Read => ({ to: '0x00000000000000000000000000000000000000aa', data: `0x6507f7cb${id.toString(16).padStart(64, '0')}` as Hex, ret: 32 }) |
| 173 | test('a 20-proposal scan splits into chunks that each fit the payload limit', () => { |
| 174 | const calls = Array.from({ length: 20 }, (_, i) => [getP(BigInt(i + 1)), word(BigInt(i + 1))]).flat() |
| 175 | const chunks = chunkReads(calls) |
| 176 | expect(chunks.flat()).toEqual(calls) |
| 177 | for (const c of chunks) { |
| 178 | expect(aggregate3RequestBytes(c)).toBeLessThanOrEqual(READ_PAYLOAD_BYTES) |
| 179 | expect(aggregate3ResponseBytes(c)).toBeLessThanOrEqual(READ_PAYLOAD_BYTES) |
| 180 | } |
| 181 | expect(chunks.length).toBe(4) |
| 182 | }) |
| 183 | test('request size estimate equals the real aggregate3 calldata', () => { |
| 184 | const calls = [getP(1n), word(1n), word(2n)] |
| 185 | expect(aggregate3RequestBytes(calls)).toBe((encodeAggregate3(calls).length - 2) / 2) |
| 186 | }) |
| 187 | test('response size estimate equals the real aggregate3 return data', () => { |
| 188 | const rets = [`0x${'11'.repeat(320)}`, `0x${'22'.repeat(32)}`] as Hex[] |
| 189 | const enc = encodeFunctionResult({ abi: MULTICALL3_ABI, functionName: 'aggregate3', result: rets.map((r) => ({ success: true, returnData: r })) }) |
| 190 | expect(aggregate3ResponseBytes([getP(1n), word(1n)])).toBe((enc.length - 2) / 2) |
| 191 | expect(decodeAggregate3(enc, 2)).toEqual(rets) |
| 192 | }) |
| 193 | test('a failed call inside the batch throws', () => { |
| 194 | const enc = encodeFunctionResult({ abi: MULTICALL3_ABI, functionName: 'aggregate3', result: [{ success: false, returnData: '0x' }] }) |
| 195 | expect(() => decodeAggregate3(enc, 1)).toThrow() |
| 196 | }) |
| 197 | test('one call costs one read', () => expect(readCost([word(1n)])).toBe(1)) |
| 198 | test('ABI selectors match the deployed AirlockAdmin', () => { |
| 199 | const sel = (fn: 'clearanceOf' | 'proposalDigest') => AIRLOCK_ADMIN_ABI.find((x) => x.type === 'function' && x.name === fn) |
| 200 | expect(sel('clearanceOf')).toBeDefined() |
| 201 | expect(keccak256(stringToBytes('clearanceOf(uint256)')).slice(0, 10)).toBe('0x6507f7cb') |
| 202 | expect(keccak256(stringToBytes('proposalDigest(uint256)')).slice(0, 10)).toBe('0xa1a5b7cb') |
| 203 | }) |
| 204 | }) |
| 205 | |
| 206 | describe('config', () => { |
| 207 | const ok = { |
| 208 | chainSelectorName: 'ethereum-testnet-sepolia', |
| 209 | chainId: 11155111, |
| 210 | airlockAdmin: '0x1111111111111111111111111111111111111111', |
| 211 | receiver: '0x2222222222222222222222222222222222222222', |
| 212 | proxy: '0x3333333333333333333333333333333333333333', |
| 213 | holders: ['0x4444444444444444444444444444444444444444'], |
| 214 | rpcs: [ |
| 215 | { url: 'https://a.example', label: 'a' }, |
| 216 | { url: 'https://b.example', label: 'b' }, |
| 217 | ], |
| 218 | minAgree: 2, |
| 219 | } |
| 220 | test('defaults', () => { |
| 221 | const c = configSchema.parse(ok) |
| 222 | expect([c.logConfidence, c.sweepSchedule, c.sweepWindow, c.multicall]).toEqual(['LATEST', '0 * * * * *', 20, '0xcA11bde05977b3631167028862bE2a173976CA11']) |
| 223 | }) |
| 224 | test('only LATEST confidence is accepted', () => { |
| 225 | expect(configSchema.safeParse({ ...ok, logConfidence: 'FINALIZED' }).success).toBe(false) |
| 226 | expect(configSchema.safeParse({ ...ok, logConfidence: 'SAFE' }).success).toBe(false) |
| 227 | }) |
| 228 | test('the quorum must be reachable', () => expect(configSchema.safeParse({ ...ok, minAgree: 3 }).success).toBe(false)) |
| 229 | test('provider labels are unique', () => expect(configSchema.safeParse({ ...ok, rpcs: [ok.rpcs[0], { ...ok.rpcs[1], label: 'a' }] }).success).toBe(false)) |
| 230 | test('a cron schedule has 5 or 6 fields', () => { |
| 231 | expect(configSchema.safeParse({ ...ok, sweepSchedule: '*/30 * * * * *' }).success).toBe(true) |
| 232 | expect(configSchema.safeParse({ ...ok, sweepSchedule: 'every minute' }).success).toBe(false) |
| 233 | }) |
| 234 | }) |
| 235 | |
| 236 | describe('determinism helpers', () => { |
| 237 | test('actors depend on the proposal only', () => { |
| 238 | const a = proposalActors(11155111n, '0x3d8559f13a4392E8e6f58A6c7143256193cbDEEa', 1n, keccak256('0x02')) |
| 239 | const b = proposalActors(11155111n, '0x3d8559f13a4392E8e6f58A6c7143256193cbDEEa', 1n, keccak256('0x02')) |
| 240 | const c = proposalActors(11155111n, '0x3d8559f13a4392E8e6f58A6c7143256193cbDEEa', 2n, keccak256('0x02')) |
| 241 | const d = proposalActors(11155111n, '0x3d8559f13a4392E8e6f58A6c7143256193cbDEEa', 1n, keccak256('0x03')) |
| 242 | expect(a).toEqual(b) |
| 243 | expect(a.stranger).not.toBe(c.stranger) |
| 244 | expect(a.stranger).not.toBe(d.stranger) |
| 245 | expect(a.stranger).not.toBe(a.receiver) |
| 246 | }) |
| 247 | test('fallback probe never reuses a known selector', () => { |
| 248 | const s = '0x1111111111111111111111111111111111111111' as Address |
| 249 | const first = fallbackCalldata(s, [])[1].data.slice(0, 10) |
| 250 | expect(fallbackCalldata(s, [first])[1].data.slice(0, 10)).not.toBe(first) |
| 251 | }) |
| 252 | test('base64 matches the reference encoder', () => { |
| 253 | for (const s of ['', 'a', 'ab', 'abc', '{"jsonrpc":"2.0","method":"eth_simulateV1"}', '中文 ✓']) { |
| 254 | expect(utf8ToBase64(s)).toBe(Buffer.from(s, 'utf8').toString('base64')) |
| 255 | } |
| 256 | }) |
| 257 | test('stableStringify sorts keys at every level', () => { |
| 258 | expect(stableStringify({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: 3 } })).toBe('{"a":{"c":3,"d":[2,{"y":2,"z":1}]},"b":1}') |
| 259 | }) |
| 260 | }) |