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 / test / unit.test.ts
cre/veto/test/unit.test.ts260 lines · 17343 bytes
1import { describe, expect, test } from 'bun:test'
2import { type Address, encodeAbiParameters, encodeFunctionResult, type Hex, keccak256, parseAbiParameters, recoverAddress, stringToBytes } from 'viem'
3import { privateKeyToAccount } from 'viem/accounts'
4import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, MULTICALL3_ABI, ZERO_HASH } from '../abi'
5import { dangerousOpcodes } from '../bytecode'
6import { aggregate3RequestBytes, aggregate3ResponseBytes, chunkReads, decodeAggregate3, encodeAggregate3, READ_PAYLOAD_BYTES, type Read, readCost } from '../chain'
7import { configSchema } from '../config'
8import { judgeConfig, type ProviderOutcome, quorum, staticUpgradeCheck } from '../judge'
9import { actionHash, upgradeDataKey } from '../keys'
10import { actionOf, buildSignedReport, canonicalEvidence, decodeReport, evidenceHashOf, proposalDigest, stableStringify, VERDICT_TYPE, verdictDigest } from '../report'
11import { RULE_IDS, ruleHash } from '../rules'
12import { buildSimulation, fallbackCalldata, proposalActors } from '../simulate'
13import { utf8ToBase64 } from '../workflow'
14
15const pass = (p: string): ProviderOutcome => ({ provider: p, verdict: 'pass', rule: null, checks: [], culprits: [], flaggedOps: [], probes: 10, selectors: 2, requests: 1 })
16const 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})
27const err = (p: string): ProviderOutcome => ({ provider: p, verdict: 'error', error: 'timeout' })
28
29describe('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
50describe('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
65describe('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
75describe('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
87describe('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
112describe('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
170describe('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
206describe('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
236describe('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})