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. |
| 4 | import { beforeEach, describe, expect, test } from 'bun:test' |
| 5 | import { type Address, decodeFunctionData, encodeFunctionResult, type Hex, keccak256, recoverAddress } from 'viem' |
| 6 | import { privateKeyToAccount } from 'viem/accounts' |
| 7 | import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, MULTICALL3_ABI, STATUS, TOKEN_ABI, ZERO_HASH } from '../abi' |
| 8 | import { type Chain, handleProposed, handleSweep, judgeCost, type Ports, type Proposal, scanCost, sweepCandidates, sweepWindow } from '../airlock' |
| 9 | import { BudgetError, chunkReads, decodeAggregate3, LIMITS, type Read } from '../chain' |
| 10 | import { type Config, configSchema, MAX_SWEEP_WINDOW } from '../config' |
| 11 | import type { UpgradeContext, Verdict } from '../judge' |
| 12 | import { actionHash, upgradeDataKey } from '../keys' |
| 13 | import { canonicalEvidence, decodeReport, evidenceHashOf, proposalDigest, verdictDigest } from '../report' |
| 14 | import { ruleHash } from '../rules' |
| 15 | import { proposalActors } from '../simulate' |
| 16 | |
| 17 | const ATTESTER_KEY = '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a' as Hex // anvil #2, public |
| 18 | const ADMIN = '0x00000000000000000000000000000000000a1a00' as Address |
| 19 | const RECEIVER = '0x00000000000000000000000000000000000ec000' as Address |
| 20 | const PROXY = '0x0000000000000000000000000000000000090000' as Address |
| 21 | const OWNER = '0x0000000000000000000000000000000000000001' as Address |
| 22 | const HOLDERS = ['0x00000000000000000000000000000000000000a1', '0x00000000000000000000000000000000000000a2', '0x00000000000000000000000000000000000000a3'] as Address[] |
| 23 | const GOOD = '0x00000000000000000000000000000000000000c1' as Address |
| 24 | const EVIL = '0x00000000000000000000000000000000000000e1' as Address |
| 25 | const EVIL2 = '0x00000000000000000000000000000000000000e2' as Address |
| 26 | |
| 27 | const 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 | |
| 43 | type P = Omit<Proposal, 'clearance' | 'oracleJudged'> |
| 44 | |
| 45 | /** AirlockAdmin + AirlockReceiver + Multicall3, enough of them to run the workflow logic. */ |
| 46 | class 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 | |
| 193 | let chain: FakeChain |
| 194 | let sims: UpgradeContext[] |
| 195 | let logs: string[] |
| 196 | /** Simulation outcome per implementation (what the DON would agree on). */ |
| 197 | const 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 | } |
| 202 | const 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 | }) |
| 211 | const run = <T>(f: () => T): T => { |
| 212 | chain.newRun() |
| 213 | return f() |
| 214 | } |
| 215 | |
| 216 | beforeEach(() => { |
| 217 | chain = new FakeChain() |
| 218 | sims = [] |
| 219 | logs = [] |
| 220 | }) |
| 221 | |
| 222 | describe('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 | |
| 296 | describe('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 | |
| 404 | describe('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 | |
| 464 | describe('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 | }) |