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 / airlock.test.ts
cre/veto/test/airlock.test.ts515 lines · 25313 bytes
1// Both handlers against an in-memory AirlockAdmin + AirlockReceiver: the same checks the contracts make
2// (forwarded report, attester signature, freshness, proposal digest, veto wins over clearance, oracleJudged set only
3// by the receiver's verdicts), with the CRE per-run read budget enforced.
4import { beforeEach, describe, expect, test } from 'bun:test'
5import { type Address, decodeFunctionData, encodeFunctionResult, type Hex, keccak256, recoverAddress } from 'viem'
6import { privateKeyToAccount } from 'viem/accounts'
7import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, MULTICALL3_ABI, STATUS, TOKEN_ABI, ZERO_HASH } from '../abi'
8import { type Chain, handleProposed, handleSweep, judgeCost, type Ports, type Proposal, scanCost, sweepCandidates, sweepWindow } from '../airlock'
9import { BudgetError, chunkReads, decodeAggregate3, LIMITS, type Read } from '../chain'
10import { type Config, configSchema, MAX_SWEEP_WINDOW } from '../config'
11import type { UpgradeContext, Verdict } from '../judge'
12import { actionHash, upgradeDataKey } from '../keys'
13import { canonicalEvidence, decodeReport, evidenceHashOf, proposalDigest, verdictDigest } from '../report'
14import { ruleHash } from '../rules'
15import { proposalActors } from '../simulate'
16
17const ATTESTER_KEY = '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' as Hex // anvil #2, public
18const ADMIN = '0x00000000000000000000000000000000000a1a00' as Address
19const RECEIVER = '0x00000000000000000000000000000000000ec000' as Address
20const PROXY = '0x0000000000000000000000000000000000090000' as Address
21const OWNER = '0x0000000000000000000000000000000000000001' as Address
22const HOLDERS = ['0x00000000000000000000000000000000000000a1', '0x00000000000000000000000000000000000000a2', '0x00000000000000000000000000000000000000a3'] as Address[]
23const GOOD = '0x00000000000000000000000000000000000000c1' as Address
24const EVIL = '0x00000000000000000000000000000000000000e1' as Address
25const EVIL2 = '0x00000000000000000000000000000000000000e2' as Address
26
27const cfg: Config = configSchema.parse({
28 chainSelectorName: 'ethereum-testnet-sepolia',
29 chainId: 11155111,
30 airlockAdmin: ADMIN,
31 receiver: RECEIVER,
32 proxy: PROXY,
33 holders: HOLDERS,
34 watch: [OWNER],
35 rpcs: [
36 { url: 'https://a.example', label: 'a' },
37 { url: 'https://b.example', label: 'b' },
38 ],
39 minAgree: 2,
40 reportGraceBlocks: 50,
41})
42
43type P = Omit<Proposal, 'clearance' | 'oracleJudged'>
44
45/** AirlockAdmin + AirlockReceiver + Multicall3, enough of them to run the workflow logic. */
46class FakeChain implements Chain {
47 block = 1000n
48 delay = 25n
49 nextId = 1n
50 props = new Map<bigint, P>()
51 clearance = new Map<bigint, Hex>()
52 /** AirlockAdmin.oracleJudged: set only when the receiver (the clearance oracle) clears or vetoes */
53 judged = new Map<bigint, boolean>()
54 approvals = new Map<Hex, bigint>() // key -> block it was approved in
55 // the first holder is empty: the second one makes the test transfer
56 balances = new Map<string, bigint>([
57 [HOLDERS[0], 0n],
58 [HOLDERS[1], 5_000_000_000n],
59 [HOLDERS[2], 25n],
60 ])
61 used = 0
62 readsAt: ('latest' | bigint)[] = []
63 reports: { action: number; id: bigint; rule: Hex; evidence: Hex; until: bigint }[] = []
64 signatures: { digest: Hex; signature: Hex; expect: Address }[] = []
65 propose(kind: number, target: Address, value = 0n, data: Hex = '0x', codehash: Hex = keccak256(target)): bigint {
66 const id = this.nextId++
67 this.props.set(id, {
68 id,
69 kind,
70 status: STATUS.Pending,
71 proposer: OWNER,
72 target: target.toLowerCase() as Address,
73 value,
74 codehash: kind === KIND.Upgrade ? codehash : ZERO_HASH,
75 dataHash: keccak256(data),
76 proposedBlock: this.block,
77 readyBlock: this.block + this.delay,
78 vetoCount: 0n,
79 })
80 this.block += 1n
81 return id
82 }
83 approve(key: Hex) {
84 this.approvals.set(key, this.block)
85 this.block += 1n
86 }
87 /** AirlockAdmin.clear by the approver: keeps the first evidence, never sets oracleJudged. */
88 approverClear(id: bigint, evidence: Hex = keccak256('0xa99207e5')) {
89 const p = this.props.get(id) as P
90 if (p.status !== STATUS.Pending) throw new Error('not pending')
91 if (!this.clearance.get(id)) this.clearance.set(id, evidence)
92 this.block += 1n
93 }
94 /** AirlockAdmin.veto by the emergency guardian: never sets oracleJudged. */
95 guardianVeto(id: bigint) {
96 const p = this.props.get(id) as P
97 if (p.status === STATUS.Pending) p.status = STATUS.Vetoed
98 p.vetoCount += 1n
99 this.block += 1n
100 }
101 newRun() {
102 this.used = 0
103 this.readsAt = []
104 }
105 private take(n = 1) {
106 if (this.used + n > LIMITS.chainReads) throw new BudgetError('ChainRead CallLimit exceeded')
107 this.used += n
108 }
109 readsLeft() {
110 return LIMITS.chainReads - this.used
111 }
112 latestBlock() {
113 this.take()
114 return this.block
115 }
116 blockHash(n: bigint): Hex {
117 this.take()
118 return keccak256(`0x${n.toString(16).padStart(64, '0')}`)
119 }
120 private answer(c: Read, at: 'latest' | bigint): Hex {
121 const b = at === 'latest' ? this.block : at
122 if (c.to.toLowerCase() === PROXY.toLowerCase()) {
123 const { args } = decodeFunctionData({ abi: TOKEN_ABI, data: c.data })
124 return encodeFunctionResult({ abi: TOKEN_ABI, functionName: 'balanceOf', result: this.balances.get((args?.[0] as string).toLowerCase()) ?? 0n })
125 }
126 if (c.to.toLowerCase() !== ADMIN.toLowerCase()) throw new Error(`unexpected read target ${c.to}`)
127 const d = decodeFunctionData({ abi: AIRLOCK_ADMIN_ABI, data: c.data })
128 const r = (functionName: string, result: unknown) => encodeFunctionResult({ abi: AIRLOCK_ADMIN_ABI, functionName, result } as never)
129 switch (d.functionName) {
130 case 'nextId':
131 return r('nextId', this.nextId)
132 case 'delayBlocks':
133 return r('delayBlocks', this.delay)
134 case 'isApprovedCodehash': {
135 const at0 = this.approvals.get((d.args[0] as Hex).toLowerCase() as Hex)
136 return r('isApprovedCodehash', at0 !== undefined && at0 <= b)
137 }
138 case 'clearanceOf':
139 return r('clearanceOf', this.clearance.get(d.args[0] as bigint) ?? ZERO_HASH)
140 case 'oracleJudged':
141 return r('oracleJudged', this.judged.get(d.args[0] as bigint) ?? false)
142 case 'getProposal': {
143 const p = this.props.get(d.args[0] as bigint)
144 const empty = { kind: 0, status: 0, proposer: OWNER, target: OWNER, value: 0n, codehash: ZERO_HASH, dataHash: ZERO_HASH, proposedBlock: 0n, readyBlock: 0n, vetoCount: 0n }
145 const { id: _id, ...rest } = p ?? { id: 0n, ...empty }
146 return r('getProposal', rest)
147 }
148 default:
149 throw new Error(`unexpected admin read ${d.functionName}`)
150 }
151 }
152 read(calls: Read[], at: 'latest' | bigint): Hex[] {
153 return chunkReads(calls).flatMap((chunk) => {
154 this.take()
155 this.readsAt.push(at)
156 if (chunk.length === 1) return [this.answer(chunk[0], at)]
157 // round-trip through the real aggregate3 encoding
158 const res = chunk.map((c) => ({ success: true, returnData: this.answer(c, at) }))
159 return decodeAggregate3(encodeFunctionResult({ abi: MULTICALL3_ABI, functionName: 'aggregate3', result: res }), chunk.length)
160 })
161 }
162 /** AirlockReceiver.onReport + AirlockAdmin (one transaction per report). */
163 write(payload: Hex) {
164 this.block += 1n
165 const tx = keccak256(payload)
166 const rep = decodeReport(payload)
167 const p = this.props.get(rep.proposalId)
168 const reverted = (why: string) => ({ ok: true, tx, error: why }) // the forwarder swallows the revert
169 if (this.block > rep.validUntilBlock) return reverted('ReportExpired')
170 // recoverAddress is async: the tests check the recorded signatures
171 this.signatures.push({ digest: verdictDigest(11155111n, RECEIVER, ADMIN, rep), signature: rep.signature, expect: privateKeyToAccount(ATTESTER_KEY).address })
172 if (!p) return reverted('UnknownProposal')
173 if (rep.proposalDigest !== proposalDigest(p)) return reverted('ProposalMismatch')
174 if (rep.action === ACTION.Veto) {
175 if (p.status === STATUS.Executed) return reverted('AlreadyExecuted')
176 p.status = STATUS.Vetoed
177 p.vetoCount += 1n
178 this.judged.set(p.id, true)
179 } else {
180 if (p.status !== STATUS.Pending) return reverted('not pending')
181 if (!this.clearance.get(p.id)) this.clearance.set(p.id, rep.evidenceHash)
182 this.judged.set(p.id, true)
183 }
184 this.reports.push({ action: rep.action, id: rep.proposalId, rule: rep.ruleHash, evidence: rep.evidenceHash, until: rep.validUntilBlock })
185 return { ok: true, tx }
186 }
187 proposal(id: bigint): Proposal {
188 const p = this.props.get(id) as P
189 return { ...p, clearance: this.clearance.get(id) ?? ZERO_HASH, oracleJudged: this.judged.get(id) ?? false }
190 }
191}
192
193let chain: FakeChain
194let sims: UpgradeContext[]
195let logs: string[]
196/** Simulation outcome per implementation (what the DON would agree on). */
197const simVerdict: Record<string, Verdict> = {
198 [GOOD]: { verdict: 'pass', rule: null },
199 [EVIL]: { verdict: 'veto', rule: 'R1_TOTAL_SUPPLY' },
200 [EVIL2]: { verdict: 'veto', rule: 'R9_NEW_OPCODES' },
201}
202const ports = (): Ports => ({
203 chain,
204 simulate: (ctx) => {
205 sims.push(ctx)
206 return simVerdict[ctx.newImpl]
207 },
208 attesterKey: () => ATTESTER_KEY,
209 log: (m) => logs.push(m),
210})
211const run = <T>(f: () => T): T => {
212 chain.newRun()
213 return f()
214}
215
216beforeEach(() => {
217 chain = new FakeChain()
218 sims = []
219 logs = []
220})
221
222describe('log trigger path', () => {
223 test('an honest upgrade is cleared, with a fresh attested report', async () => {
224 const id = chain.propose(KIND.Upgrade, GOOD)
225 const p0 = chain.proposal(id)
226 const o = run(() => handleProposed(ports(), cfg, id))
227 expect(o.action).toBe('cleared')
228 expect(chain.proposal(id).clearance).toBe(o.evidenceHash as Hex)
229 expect(chain.reports[0].action).toBe(ACTION.Clear)
230 expect(chain.reports[0].rule).toBe(ZERO_HASH)
231 // validUntilBlock = max(readyBlock, current) + grace; here readyBlock is later
232 expect(chain.reports[0].until).toBe(p0.readyBlock + 50n)
233 for (const s of chain.signatures) expect(await recoverAddress({ hash: s.digest, signature: s.signature })).toBe(s.expect)
234 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
235 expect(sims.length).toBe(1)
236 })
237 test('the simulation runs at the proposal block with actors from its block hash and every holder watched', () => {
238 const id = chain.propose(KIND.Upgrade, GOOD)
239 const p = chain.proposal(id)
240 run(() => handleProposed(ports(), cfg, id))
241 const ctx = sims[0]
242 expect(ctx.block).toBe(p.proposedBlock)
243 expect(ctx.holder).toBe(HOLDERS[1]) // the first holder with a balance
244 expect(ctx.holders).toEqual(HOLDERS)
245 const blockHash = keccak256(`0x${p.proposedBlock.toString(16).padStart(64, '0')}`)
246 expect(ctx.stranger).toBe(proposalActors(11155111n, ADMIN, id, blockHash).stranger)
247 // approvals and balances are read at the proposal block
248 expect(chain.readsAt).toContain(p.proposedBlock)
249 })
250 test('a malicious upgrade is vetoed with its rule', () => {
251 const id = chain.propose(KIND.Upgrade, EVIL)
252 const o = run(() => handleProposed(ports(), cfg, id))
253 expect([o.action, o.rule]).toEqual(['vetoed', 'R1_TOTAL_SUPPLY'])
254 expect(chain.proposal(id).status).toBe(STATUS.Vetoed)
255 expect(chain.reports[0].rule).toBe(ruleHash('R1_TOTAL_SUPPLY'))
256 })
257 test('removing a guardian without approval is vetoed R7 without any simulation', () => {
258 const id = chain.propose(KIND.SetGuardian, RECEIVER, 0n)
259 const o = run(() => handleProposed(ports(), cfg, id))
260 expect([o.action, o.rule]).toEqual(['vetoed', 'R7_CONFIG_GUARD'])
261 expect(sims.length).toBe(0)
262 })
263 test('an approval made before the proposal counts, one made after it does not', () => {
264 chain.approve(actionHash(KIND.SetDelay, '0x0000000000000000000000000000000000000000', 20n))
265 const before = chain.propose(KIND.SetDelay, '0x0000000000000000000000000000000000000000', 20n)
266 expect(run(() => handleProposed(ports(), cfg, before)).action).toBe('cleared')
267 const after = chain.propose(KIND.SetDelay, '0x0000000000000000000000000000000000000000', 15n)
268 chain.approve(actionHash(KIND.SetDelay, '0x0000000000000000000000000000000000000000', 15n))
269 expect(run(() => handleProposed(ports(), cfg, after)).rule).toBe('R7_CONFIG_GUARD')
270 })
271 test('init data needs a codehash+data approval (R8), from the dataHash alone', () => {
272 const data = `0x${'ab'.repeat(6000)}` as Hex
273 const id = chain.propose(KIND.Upgrade, GOOD, 0n, data)
274 expect(run(() => handleProposed(ports(), cfg, id)).rule).toBe('R8_INIT_DATA')
275 expect(sims.length).toBe(0)
276 const id2 = chain.propose(KIND.Upgrade, GOOD, 0n, data)
277 chain.approve(upgradeDataKey(keccak256(GOOD), keccak256(data)))
278 const id3 = chain.propose(KIND.Upgrade, GOOD, 0n, data)
279 expect(run(() => handleProposed(ports(), cfg, id2)).rule).toBe('R8_INIT_DATA') // approved after it was proposed
280 expect(run(() => handleProposed(ports(), cfg, id3)).action).toBe('cleared')
281 })
282 test('only Pending proposals are judged', () => {
283 const id = chain.propose(KIND.Upgrade, EVIL)
284 run(() => handleProposed(ports(), cfg, id))
285 const o = run(() => handleProposed(ports(), cfg, id))
286 expect(o.action).toBe('skipped')
287 expect(run(() => handleProposed(ports(), cfg, 99n)).action).toBe('skipped')
288 })
289 test('no funded holder fails closed (R6)', () => {
290 for (const h of HOLDERS) chain.balances.set(h, 0n)
291 const id = chain.propose(KIND.Upgrade, GOOD)
292 expect(run(() => handleProposed(ports(), cfg, id)).rule).toBe('R6_SIM_FAILURE')
293 })
294})
295
296describe('sweeper path', () => {
297 test('finds the proposals no verdict reached and judges them, oldest first', () => {
298 const a = chain.propose(KIND.Upgrade, GOOD) // judged by the log trigger
299 run(() => handleProposed(ports(), cfg, a))
300 const b = chain.propose(KIND.Upgrade, EVIL) // judged by the log trigger
301 run(() => handleProposed(ports(), cfg, b))
302 const c = chain.propose(KIND.Upgrade, GOOD) // missed
303 const d = chain.propose(KIND.SetGuardian, RECEIVER, 0n) // missed
304 chain.block += 30n
305 const s = run(() => handleSweep(ports(), cfg))
306 expect(s.candidates).toEqual([c.toString(), d.toString()])
307 expect(s.outcomes.map((o) => [o.id, o.action, o.rule])).toEqual([
308 [c.toString(), 'cleared', null],
309 [d.toString(), 'vetoed', 'R7_CONFIG_GUARD'],
310 ])
311 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
312 // the report is fresh although readyBlock has passed: max(readyBlock, current) + grace
313 const rep = chain.reports.find((r) => r.id === c) as { until: bigint }
314 expect(rep.until).toBeGreaterThan(chain.proposal(c).readyBlock + 50n)
315 })
316 test('at most one attack simulation per run; static and config verdicts still go through', () => {
317 const u1 = chain.propose(KIND.Upgrade, EVIL)
318 const u2 = chain.propose(KIND.Upgrade, EVIL2)
319 const big = chain.propose(KIND.Upgrade, GOOD, 0n, `0x${'cd'.repeat(6000)}`)
320 const g = chain.propose(KIND.SetGuardian, OWNER, 1n)
321 const s1 = run(() => handleSweep(ports(), cfg))
322 expect(s1.outcomes.map((o) => [o.id, o.action, o.rule ?? null])).toEqual([
323 [u1.toString(), 'vetoed', 'R1_TOTAL_SUPPLY'],
324 [u2.toString(), 'deferred', null],
325 [big.toString(), 'vetoed', 'R8_INIT_DATA'],
326 [g.toString(), 'vetoed', 'R7_CONFIG_GUARD'],
327 ])
328 expect(sims.length).toBe(1)
329 const s2 = run(() => handleSweep(ports(), cfg))
330 expect(s2.outcomes.map((o) => [o.id, o.action, o.rule])).toEqual([[u2.toString(), 'vetoed', 'R9_NEW_OPCODES']])
331 const s3 = run(() => handleSweep(ports(), cfg))
332 expect(s3.candidates).toEqual([])
333 })
334 test('stays inside the ChainRead budget and leaves the rest for the next run', () => {
335 for (let i = 0; i < 12; i++) chain.propose(KIND.SetGuardian, OWNER, 1n)
336 const s1 = run(() => handleSweep(ports(), cfg))
337 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
338 expect(s1.outcomes.some((o) => o.action === 'failed')).toBe(false)
339 const done = s1.outcomes.filter((o) => o.action === 'vetoed').length
340 expect(done).toBeGreaterThan(0)
341 expect(s1.outcomes.filter((o) => o.action === 'deferred').length).toBe(12 - done)
342 let runs = 1
343 while (sweepCandidates([...chain.props.keys()].map((id) => chain.proposal(id))).length && runs < 10) {
344 run(() => handleSweep(ports(), cfg))
345 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
346 runs++
347 }
348 expect(runs).toBeLessThan(10)
349 expect([...chain.props.values()].every((p) => p.status === STATUS.Vetoed)).toBe(true)
350 })
351 test('looks only at the newest window of ids', () => {
352 for (let i = 0; i < 25; i++) chain.propose(KIND.SetGuardian, OWNER, 1n)
353 const s = run(() => handleSweep(ports(), { ...cfg, sweepWindow: 20 }))
354 expect(s.window).toEqual(['6', '25'])
355 expect(s.candidates[0]).toBe('6')
356 expect(s.outcomes.some((o) => o.action === 'failed')).toBe(false)
357 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
358 expect(sweepWindow(3n, 20)).toEqual([1n, 2n])
359 expect(sweepWindow(1n, 20)).toEqual([])
360 })
361 test('a vetoed proposal is never cleared, a cleared one is not judged again', () => {
362 const v = chain.propose(KIND.Upgrade, EVIL)
363 run(() => handleProposed(ports(), cfg, v))
364 const c = chain.propose(KIND.Upgrade, GOOD)
365 run(() => handleProposed(ports(), cfg, c))
366 const n = chain.reports.length
367 const s = run(() => handleSweep(ports(), cfg))
368 expect(s.candidates).toEqual([])
369 expect(chain.reports.length).toBe(n)
370 expect(chain.proposal(v).clearance).toBe(ZERO_HASH)
371 })
372 test('the log path and the sweeper produce the same evidence for the same proposal', () => {
373 const id = chain.propose(KIND.Upgrade, GOOD)
374 const viaLog = run(() => handleProposed(ports(), cfg, id)).evidenceHash
375 // same chain state, judged by the sweeper instead
376 chain.clearance.delete(id)
377 chain.judged.delete(id)
378 const viaSweep = run(() => handleSweep(ports(), cfg)).outcomes[0].evidenceHash
379 expect(viaSweep).toBe(viaLog as Hex)
380 // and anyone can recompute it from getProposal + the verdict
381 const p = chain.proposal(id)
382 const e = canonicalEvidence({ chainId: 11155111n, airlockAdmin: ADMIN, proposal: p, proposalDigest: proposalDigest(p), verdict: { verdict: 'pass', rule: null } })
383 expect(evidenceHashOf(e)).toBe(viaLog as Hex)
384 })
385 test('a report the receiver rejects (here: expired) is reported as unconfirmed', () => {
386 const id = chain.propose(KIND.Upgrade, GOOD)
387 const real = chain.write.bind(chain)
388 chain.write = (payload: Hex) => {
389 chain.block += 1000n // the transaction lands long after validUntilBlock
390 return real(payload)
391 }
392 expect(run(() => handleProposed(ports(), cfg, id)).action).toBe('unconfirmed')
393 expect(chain.proposal(id).clearance).toBe(ZERO_HASH)
394 })
395 test('judgement cost estimates', () => {
396 const u = chain.propose(KIND.Upgrade, GOOD)
397 const g = chain.propose(KIND.SetGuardian, OWNER, 1n)
398 expect(judgeCost(cfg, chain.proposal(u))).toBe(3)
399 expect(judgeCost(cfg, chain.proposal(g))).toBe(2)
400 expect(EMPTY_DATA_HASH).toBe(chain.proposal(u).dataHash)
401 })
402})
403
404describe('approver clearances do not count as a workflow verdict (oracleJudged)', () => {
405 test('the sweeper judges an approver-cleared proposal whose log was missed, and its veto wins', () => {
406 const id = chain.propose(KIND.Upgrade, EVIL) // log missed
407 chain.approverClear(id)
408 expect(sweepCandidates([chain.proposal(id)]).map((p) => p.id)).toEqual([id])
409 const s = run(() => handleSweep(ports(), cfg))
410 expect(s.candidates).toEqual([id.toString()])
411 expect(s.outcomes.map((o) => [o.action, o.rule])).toEqual([['vetoed', 'R1_TOTAL_SUPPLY']])
412 expect(chain.proposal(id).status).toBe(STATUS.Vetoed)
413 expect(chain.proposal(id).oracleJudged).toBe(true)
414 expect(logs.some((l) => l.includes(`#${id}(approver-cleared)`))).toBe(true)
415 // judged now: the next sweep leaves it alone
416 expect(run(() => handleSweep(ports(), cfg)).candidates).toEqual([])
417 })
418 test('an approver-cleared honest proposal gets its workflow Clear; clearanceOf keeps the first evidence', () => {
419 const id = chain.propose(KIND.Upgrade, GOOD)
420 const manual = keccak256('0x6d616e75616c')
421 chain.approverClear(id, manual)
422 const o = run(() => handleSweep(ports(), cfg)).outcomes[0]
423 expect(o.action).toBe('cleared')
424 expect(o.reason).toContain('keeps the earlier clearance')
425 expect(chain.proposal(id).clearance).toBe(manual)
426 expect(chain.proposal(id).oracleJudged).toBe(true)
427 expect(sims.length).toBe(1)
428 })
429 test('the log trigger also judges a proposal the approver cleared first', () => {
430 const id = chain.propose(KIND.Upgrade, EVIL2)
431 chain.approverClear(id)
432 const o = run(() => handleProposed(ports(), cfg, id))
433 expect([o.action, o.rule]).toEqual(['vetoed', 'R9_NEW_OPCODES'])
434 })
435 test('a proposal the workflow already judged is skipped by the log trigger (duplicate delivery)', () => {
436 const id = chain.propose(KIND.Upgrade, GOOD)
437 expect(run(() => handleProposed(ports(), cfg, id)).action).toBe('cleared')
438 const n = chain.reports.length
439 const again = run(() => handleProposed(ports(), cfg, id))
440 expect([again.action, again.reason]).toEqual(['skipped', 'already judged by this workflow'])
441 expect(chain.reports.length).toBe(n)
442 })
443 test('a rejected Clear on an approver-cleared proposal is reported as unconfirmed (clearanceOf alone is no proof)', () => {
444 const id = chain.propose(KIND.Upgrade, GOOD)
445 chain.approverClear(id)
446 const real = chain.write.bind(chain)
447 chain.write = (payload: Hex) => {
448 chain.block += 1000n // lands after validUntilBlock: the receiver reverts, the forwarder swallows it
449 return real(payload)
450 }
451 const o = run(() => handleProposed(ports(), cfg, id))
452 expect(o.action).toBe('unconfirmed')
453 expect(chain.proposal(id).clearance).not.toBe(ZERO_HASH)
454 expect(chain.proposal(id).oracleJudged).toBe(false)
455 })
456 test("an emergency guardian's veto leaves nothing for the sweeper", () => {
457 const id = chain.propose(KIND.Upgrade, GOOD)
458 chain.guardianVeto(id)
459 expect(chain.proposal(id).oracleJudged).toBe(false)
460 expect(run(() => handleSweep(ports(), cfg)).candidates).toEqual([])
461 })
462})
463
464describe('sweep window and run budgets', () => {
465 test(`the largest sweepWindow (${MAX_SWEEP_WINDOW}) leaves room for an upgrade and two config changes`, () => {
466 const fixed = 2 // latest header, nextId
467 const upgrade = 3
468 const config = 2
469 expect(fixed + scanCost(cfg, MAX_SWEEP_WINDOW) + upgrade + 2 * config).toBeLessThanOrEqual(LIMITS.chainReads)
470 expect(configSchema.safeParse({ ...cfg, sweepWindow: MAX_SWEEP_WINDOW }).success).toBe(true)
471 expect(configSchema.safeParse({ ...cfg, sweepWindow: MAX_SWEEP_WINDOW + 1 }).success).toBe(false)
472 // the values that used to pass the schema and break every sweep
473 expect(configSchema.safeParse({ ...cfg, sweepWindow: 79 }).success).toBe(false)
474 expect(configSchema.safeParse({ ...cfg, sweepWindow: 100 }).success).toBe(false)
475 })
476 test('a full window of pending proposals: the run stays in budget and judges an upgrade and config changes', () => {
477 for (let i = 0; i < MAX_SWEEP_WINDOW; i++) i % 2 ? chain.propose(KIND.SetGuardian, OWNER, 1n) : chain.propose(KIND.Upgrade, EVIL)
478 const s = run(() => handleSweep(ports(), { ...cfg, sweepWindow: MAX_SWEEP_WINDOW }))
479 expect(chain.used).toBeLessThanOrEqual(LIMITS.chainReads)
480 expect(s.candidates.length).toBe(MAX_SWEEP_WINDOW)
481 expect(s.error).toBeUndefined()
482 expect(s.outcomes.filter((o) => o.action === 'vetoed' && o.kind === KIND.Upgrade).length).toBe(1)
483 expect(s.outcomes.filter((o) => o.action === 'vetoed' && o.kind === KIND.SetGuardian).length).toBeGreaterThanOrEqual(2)
484 })
485 test('a budget that runs out during the scan ends the run with a log line, not an error', () => {
486 for (let i = 0; i < 20; i++) chain.propose(KIND.SetGuardian, OWNER, 1n)
487 const s = run(() => {
488 chain.used = LIMITS.chainReads - 3 // something else already used most of this run's reads
489 return handleSweep(ports(), cfg)
490 })
491 expect(s.error).toContain('ChainRead')
492 expect(s.outcomes).toEqual([])
493 expect(logs.some((l) => l.startsWith('sweep stopped before judging anything'))).toBe(true)
494 })
495 test('a budget error while judging defers that proposal and the rest, and the run still returns', () => {
496 const a = chain.propose(KIND.Upgrade, GOOD)
497 const b = chain.propose(KIND.SetGuardian, OWNER, 1n)
498 chain.blockHash = () => {
499 throw new BudgetError('ChainRead CallLimit exceeded')
500 }
501 const s = run(() => handleSweep(ports(), cfg))
502 expect(s.outcomes.map((o) => [o.id, o.action])).toEqual([
503 [a.toString(), 'deferred'],
504 [b.toString(), 'deferred'],
505 ])
506 expect(s.error).toContain('ChainRead')
507 })
508 test('the log path defers on a budget error instead of failing the run', () => {
509 const id = chain.propose(KIND.Upgrade, GOOD)
510 chain.blockHash = () => {
511 throw new BudgetError('ChainRead CallLimit exceeded')
512 }
513 expect(run(() => handleProposed(ports(), cfg, id)).action).toBe('deferred')
514 })
515})