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 / sim.test.ts
cre/veto/test/sim.test.ts648 lines · 37476 bytes
1// The attack simulation against an in-memory token (a small eth_simulateV1 that runs ERC-20 calls with optional
2// malicious functions), and the bytecode walker against crafted bytecode.
3import { describe, expect, test } from 'bun:test'
4import { type Address, encodeAbiParameters, encodeEventTopics, encodeFunctionResult, type Hex, keccak256, stringToBytes } from 'viem'
5import { TOKEN_ABI } from '../abi'
6import { codeBytes, codeLayout, dangerousOpcodes, extractSelectors, solcMetadataStart } from '../bytecode'
7import { LIMITS } from '../chain'
8import { configSchema } from '../config'
9import { judgeOnProvider, type ProviderOutcome, quorum, type RpcBatch, type RpcCall, r9Flagged, simulateUpgrade, type UpgradeContext } from '../judge'
10import { ARG_SLOTS, BASE_SELECTORS, buildSimulation, newSelectors, PROBE_SHAPES, rpcBody, SIM_REQUEST_BYTES, STRANGER_ETH } from '../simulate'
11import { utf8ToBase64 } from '../workflow'
12
13const sel = (sig: string) => keccak256(stringToBytes(sig)).slice(0, 10)
14const SEL = {
15 totalSupply: sel('totalSupply()'),
16 owner: sel('owner()'),
17 balanceOf: sel('balanceOf(address)'),
18 transfer: sel('transfer(address,uint256)'),
19 transferFrom: sel('transferFrom(address,address,uint256)'),
20 approve: sel('approve(address,uint256)'),
21 mint: sel('mint(address,uint256)'),
22 // malicious, non-standard selectors (each NEW relative to the base interface)
23 rescue: sel('rescue(address,address,uint256)'), // open rescue(from, to, amount)
24 rescueA: sel('rescueA(address,uint256,address)'), // reordered: rescue(from, amount, to)
25 sweepB: sel('sweepB(uint256,address,address)'), // reordered: sweep(amount, from, to)
26 rescueC: sel('rescueC(address,address,address,uint256)'), // rescueTokens(token, from, to, amount), token == this
27 rescueE: sel('rescueE(address,address,uint256)'), // drain with no Transfer event
28 flashMint: sel('flashMint()'),
29 poke: sel('poke(address,uint256)'),
30 borrow: sel('borrow(address,uint256)'),
31}
32const ERC20 = [SEL.totalSupply, SEL.owner, SEL.balanceOf, SEL.transfer, SEL.transferFrom, SEL.approve]
33
34/** Selectors that do nothing (they revert), to make a contract with many functions. The filler names are NEW. */
35const filler = (n: number) => Array.from({ length: n }, (_, i) => sel(`filler${i}()`))
36/** Base-interface filler selectors (allowance/name/symbol/... style), NOT new, so they get no placement probes. */
37const BASE_FILLERS = ['allowance(address,address)', 'name()', 'symbol()', 'decimals()', 'nonces(address)', 'pendingOwner()', 'proxiableUUID()', 'burn(uint256)'].map(sel)
38
39/** A solc-style dispatcher (PUSH4 sel DUP2 EQ PUSH2 dest JUMPI per selector), optional extra code, then INVALID. */
40const dispatcher = (selectors: string[], extra = '') => `0x${selectors.map((s) => `63${s.slice(2)}811461000057`).join('')}${extra}fe` as Hex
41
42const PROXY = '0x0000000000000000000000000000000000090000' as Address
43const IMPL = '0x00000000000000000000000000000000000b0002' as Address
44const OWNER = '0x0000000000000000000000000000000000000001' as Address
45const HOLDERS = ['0x00000000000000000000000000000000000000a1', '0x00000000000000000000000000000000000000a2'] as Address[]
46const FUNDER = '0x00000000000000000000000000000000000000a3' as Address
47const STRANGER = '0x00000000000000000000000000000000000000d1' as Address
48const RECEIVER = '0x00000000000000000000000000000000000000d2' as Address
49const BAL = 5_000_000_000n
50
51type Behaviour = 'drain' | 'borrow' | 'flashMint' | 'zeroTransfer' | 'openMint' | 'reorderA' | 'reorderB' | 'tokenGuardC' | 'noLogE' | 'requireTokens' | 'requireEth' | 'allowanceSkip'
52type Log = { address: Address; topics: Hex[]; data: Hex }
53
54/** In-memory token behind the proxy: ERC-20 + Ownable reads, plus the selected malicious functions. */
55class FakeEvm {
56 calls: RpcCall[][] = []
57 constructor(
58 public code: Hex,
59 public behaviours: Behaviour[] = [],
60 ) {}
61 rpc: RpcBatch = (_url, calls) => {
62 this.calls.push(calls)
63 return calls.map((c) => {
64 if (c.method === 'eth_getCode') return (c.params[0] as string).toLowerCase() === IMPL ? this.code : '0x'
65 if (c.method === 'eth_simulateV1') return this.simulate(c.params)
66 throw new Error(`unexpected method ${c.method}`)
67 })
68 }
69 private simulate(params: unknown[]) {
70 const [{ blockStateCalls }] = params as [{ blockStateCalls: { stateOverrides: Record<string, { balance?: string }>; calls: { from: Address; to: Address; data: Hex }[] }[] }]
71 if (blockStateCalls.length !== 1 || !blockStateCalls[0].stateOverrides[PROXY]) throw new Error('expected one block with the proxy override')
72 // simulated ETH balances come from the stateOverrides (the stranger is funded with ETH there)
73 const eth = new Map<string, bigint>()
74 for (const [addr, o] of Object.entries(blockStateCalls[0].stateOverrides)) if (o.balance) eth.set(addr.toLowerCase(), BigInt(o.balance))
75 const ethOf = (a: string) => eth.get(a.toLowerCase()) ?? 0n
76 // every request starts from the proposal block's state (the test holder funded, the funder funded, stranger empty)
77 const bal = new Map<string, bigint>([
78 [HOLDERS[0], 0n],
79 [HOLDERS[1], BAL],
80 [FUNDER, BAL],
81 ])
82 const allow = new Map<string, bigint>()
83 let supply = BAL * 2n
84 const owner = OWNER as string
85 const get = (a: string) => bal.get(a.toLowerCase()) ?? 0n
86 const results = blockStateCalls[0].calls.map(({ from, to, data }) => {
87 const logs: Log[] = []
88 const revert = { status: '0x0', returnData: '0x', gasUsed: '0x5208', logs: [], error: { message: 'execution reverted' } }
89 const ok = (returnData: Hex = '0x') => ({ status: '0x1', returnData, gasUsed: '0x5208', logs })
90 const transferLog = (f: string, t: string, v: bigint) =>
91 logs.push({
92 address: PROXY,
93 topics: encodeEventTopics({ abi: TOKEN_ABI, eventName: 'Transfer', args: { from: f as Address, to: t as Address } }) as Hex[],
94 data: encodeAbiParameters([{ type: 'uint256' }], [v]),
95 })
96 const move = (f: string, t: string, v: bigint, emit = true) => {
97 if (get(f) < v) return false
98 bal.set(f.toLowerCase(), get(f) - v)
99 bal.set(t.toLowerCase(), get(t) + v)
100 if (emit) transferLog(f, t, v)
101 return true
102 }
103 if (to.toLowerCase() !== PROXY) return revert
104 const s = data.slice(0, 10)
105 const words = (n: number) => {
106 if ((data.length - 10) / 64 < n) return null // calldata too short: solc's decoder reverts
107 return Array.from({ length: n }, (_, i) => `0x${data.slice(10 + 64 * i, 74 + 64 * i)}` as Hex)
108 }
109 const addr = (w: Hex) => (BigInt(w) >> 160n ? null : (`0x${w.slice(-40)}` as Address))
110 /** `n` ABI words: the first `addrs` as addresses, the rest as uint256; all null if the calldata is too short or
111 * an address word has high bits set (solc's decoder reverts on both) */
112 const args = (n: number, addrs: number): (string | bigint | null)[] => {
113 const w = words(n)
114 if (!w) return Array(n).fill(null)
115 const out = w.map((x, i) => (i < addrs ? addr(x) : BigInt(x)))
116 return out.slice(0, addrs).some((a) => a === null) ? Array(n).fill(null) : out
117 }
118 const has = (b: Behaviour) => this.behaviours.includes(b)
119 const T = (fn: 'totalSupply' | 'owner' | 'balanceOf' | 'transfer' | 'transferFrom', result: unknown) => encodeFunctionResult({ abi: TOKEN_ABI, functionName: fn, result } as never)
120 switch (s) {
121 case SEL.totalSupply:
122 return ok(T('totalSupply', supply))
123 case SEL.owner:
124 return ok(T('owner', owner))
125 case SEL.balanceOf: {
126 const [a] = args(1, 1)
127 return a ? ok(T('balanceOf', get(a as Address))) : revert
128 }
129 case SEL.transfer: {
130 const [t, v] = args(2, 1)
131 return t && move(from, t as Address, v as bigint) ? ok(T('transfer', true)) : revert
132 }
133 case SEL.approve: {
134 const [sp, v] = args(2, 1)
135 if (!sp) return revert
136 allow.set(`${from.toLowerCase()}:${(sp as string).toLowerCase()}`, v as bigint)
137 return ok(T('transfer', true))
138 }
139 case SEL.transferFrom: {
140 const [f, t, v] = args(3, 2)
141 if (!f || !t) return revert
142 const key = `${(f as string).toLowerCase()}:${from.toLowerCase()}`
143 const amount = v as bigint
144 // G: a malicious transferFrom waives the allowance check when the caller already holds tokens
145 const skip = has('allowanceSkip') && get(from) > 0n
146 if (!skip && ((allow.get(key) ?? 0n) < amount || get(f as string) < amount)) return revert
147 if (skip && get(f as string) < amount) return revert
148 if (!skip) allow.set(key, (allow.get(key) ?? 0n) - amount)
149 move(f as string, t as string, amount)
150 return ok(T('transferFrom', true))
151 }
152 case SEL.rescue: {
153 // open rescue(from, to, amount), optionally gated on the caller holding tokens (D) or ETH (F)
154 if (!has('drain') && !has('requireTokens') && !has('requireEth')) return revert
155 if (has('requireTokens') && get(from) === 0n) return revert
156 if (has('requireEth') && ethOf(from) === 0n) return revert
157 const [f, t, v] = args(3, 2)
158 return f && t && move(f as string, t as string, v as bigint) ? ok() : revert
159 }
160 case SEL.rescueA: {
161 // A: rescueA(from, amount, to) -> _transfer(from, to, amount)
162 if (!has('reorderA')) return revert
163 const w = words(3)
164 if (!w) return revert
165 const f = addr(w[0])
166 const t = addr(w[2])
167 return f && t && move(f, t, BigInt(w[1])) ? ok() : revert
168 }
169 case SEL.sweepB: {
170 // B: sweepB(amount, from, to) -> _transfer(from, to, amount)
171 if (!has('reorderB')) return revert
172 const w = words(3)
173 if (!w) return revert
174 const f = addr(w[1])
175 const t = addr(w[2])
176 return f && t && move(f, t, BigInt(w[0])) ? ok() : revert
177 }
178 case SEL.rescueC: {
179 // C: rescueC(token, from, to, amount) with require(token == address(this) == proxy)
180 if (!has('tokenGuardC')) return revert
181 const w = words(4)
182 if (!w) return revert
183 const token = addr(w[0])
184 const f = addr(w[1])
185 const t = addr(w[2])
186 if (!token || token.toLowerCase() !== PROXY.toLowerCase()) return revert
187 return f && t && move(f, t, BigInt(w[3])) ? ok() : revert
188 }
189 case SEL.rescueE: {
190 // E: rescueE(from, to, amount) moves balances with NO Transfer event
191 if (!has('noLogE')) return revert
192 const [f, t, v] = args(3, 2)
193 return f && t && move(f as string, t as string, v as bigint, false) ? ok() : revert
194 }
195 case SEL.borrow: {
196 // takes `amount` of `from`'s tokens and gives them back in the same call: no balance changes in the end
197 if (!has('borrow')) return revert
198 const [f, v] = args(2, 1)
199 if (!f || !move(f as string, from, v as bigint)) return revert
200 move(from, f as string, v as bigint)
201 return ok()
202 }
203 case SEL.flashMint: {
204 // mints to the caller and burns it back in the same call: totalSupply ends where it started
205 if (!has('flashMint')) return revert
206 transferLog('0x0000000000000000000000000000000000000000', from, 1000n)
207 transferLog(from, '0x0000000000000000000000000000000000000000', 1000n)
208 return ok()
209 }
210 case SEL.poke: {
211 // emits a zero-value Transfer out of the given address: moves nothing
212 if (!has('zeroTransfer')) return revert
213 const [a] = args(2, 1)
214 if (!a) return revert
215 transferLog(a as string, from, 0n)
216 return ok()
217 }
218 default:
219 if (s === SEL.mint && has('openMint')) {
220 const [t, v] = args(2, 1)
221 if (!t) return revert
222 supply += v as bigint
223 bal.set((t as string).toLowerCase(), get(t as string) + (v as bigint))
224 transferLog('0x0000000000000000000000000000000000000000', t as string, v as bigint)
225 return ok()
226 }
227 return revert
228 }
229 })
230 return [{ number: '0x1', calls: results }]
231 }
232}
233
234const ctxFor = (code: Hex, over: Partial<UpgradeContext> = {}): UpgradeContext => ({
235 proxy: PROXY,
236 newImpl: IMPL,
237 codehash: keccak256(code),
238 hasInitData: false,
239 block: 100n,
240 holder: HOLDERS[1],
241 amount: BAL / 100n,
242 funder: FUNDER,
243 fundAmount: BAL / 100n,
244 holders: HOLDERS,
245 watch: [OWNER],
246 stranger: STRANGER,
247 receiver: RECEIVER,
248 strict: false,
249 codehashApproved: false,
250 dataApproved: false,
251 callGas: 1_000_000,
252 readGas: 200_000,
253 maxCalls: 250,
254 simRequests: 2,
255 allowedOpcodes: [],
256 ...over,
257})
258
259const judge = (evm: FakeEvm, over: Partial<UpgradeContext> = {}) => judgeOnProvider(evm.rpc, 'http://fake', 'fake', ctxFor(evm.code, over))
260const verdictOf = (o: ProviderOutcome) => (o.verdict === 'error' ? `error: ${o.error}` : o.verdict === 'pass' ? 'pass' : o.rule)
261
262describe('the stranger is funded before the probes', () => {
263 test('the first request funds the stranger from a second holder, before the baseline reads, with an ETH override', () => {
264 const code = dispatcher([...ERC20, SEL.rescue])
265 const { layout, requests } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxCalls: 60, maxRequests: 2 })
266 const chunk0 = layout.chunks[0]
267 const calls = (requests[0][0] as { blockStateCalls: { stateOverrides: Record<string, { balance?: string }>; calls: { from: Address; to: Address; data: string }[] }[] }).blockStateCalls[0]
268 // the funding transfer is the very first call, from the funder to the stranger
269 expect(chunk0.fund).toBe(0)
270 expect(calls.calls[0].from.toLowerCase()).toBe(FUNDER.toLowerCase())
271 expect(calls.calls[0].data.slice(0, 10)).toBe(SEL.transfer)
272 expect(calls.calls[0].data).toContain(STRANGER.slice(2).toLowerCase())
273 // funding comes before the baseline reads (r0) and the test transfer
274 expect(chunk0.fund).toBeLessThan(chunk0.r0?.totalSupply ?? -1)
275 expect(chunk0.r0?.totalSupply).toBeLessThan(chunk0.transfer)
276 // the stranger has an ETH override
277 expect(BigInt(calls.stateOverrides[STRANGER.toLowerCase()].balance ?? '0x0')).toBe(STRANGER_ETH)
278 // every later request funds the stranger again (each starts from the proposal block)
279 expect(layout.chunks[1]?.fund).toBe(0)
280 // the stranger is on the watch list
281 expect(layout.watchList).toContain(STRANGER.toLowerCase())
282 })
283 test('with no second funded holder, there is no funding transfer (ETH override still applies)', () => {
284 const code = dispatcher([...ERC20, SEL.rescue])
285 const { layout, requests } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], funder: null, fundAmount: 0n, maxRequests: 2 })
286 expect(layout.funder).toBeNull()
287 for (const c of layout.chunks) expect(c.fund).toBe(-1)
288 const calls = (requests[0][0] as { blockStateCalls: { stateOverrides: Record<string, { balance?: string }>; calls: unknown[] }[] }).blockStateCalls[0]
289 expect(BigInt(calls.stateOverrides[STRANGER.toLowerCase()].balance ?? '0x0')).toBe(STRANGER_ETH)
290 })
291 test('a funding transfer that reverts fails closed (R6), never a pass', () => {
292 // the funder holds nothing, so the transfer reverts; the drains it would reach are not trusted
293 const code = dispatcher([...ERC20, SEL.rescue])
294 const o = judge(new FakeEvm(code), { funder: HOLDERS[0], fundAmount: BAL / 100n }) // HOLDERS[0] balance is 0
295 expect(verdictOf(o)).toBe('R6_SIM_FAILURE')
296 })
297})
298
299describe('placement probes are generated for NEW selectors only', () => {
300 test('base-interface selectors get no placement probes; a new selector does', () => {
301 expect(newSelectors(ERC20)).toEqual([])
302 expect(newSelectors([...ERC20, SEL.rescue])).toEqual([SEL.rescue])
303 for (const s of ERC20) expect(BASE_SELECTORS.has(s)).toBe(true)
304 })
305 test('each new selector gets holder-in-each-slot x amount-in-each-other-slot x {stranger, proxy} placement probes', () => {
306 const code = dispatcher([...ERC20, SEL.rescue])
307 const { layout } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxRequests: 2 })
308 const placement = layout.chunks.flatMap((c) => c.probes).filter((p) => p.selector === SEL.rescue && p.args.startsWith('h'))
309 expect(placement.length).toBe(ARG_SLOTS * (ARG_SLOTS - 1) * 2) // 24
310 // one of them is holder in slot 0, amount in slot 2, remaining filled with the stranger
311 expect(placement.some((p) => p.args === 'h0,a2,s')).toBe(true)
312 // and one fills the remaining slots with the proxy (needed for a require(token == address(this)) guard)
313 expect(placement.some((p) => p.args === 'h1,a3,p')).toBe(true)
314 // placement probes carry a holder and a stranger balance read right after them
315 for (const p of placement) {
316 expect(p.holderRead).toBeGreaterThan(p.index)
317 expect(p.strangerRead).toBeGreaterThan(p.holderRead)
318 }
319 // the placement probe really puts the holder, the test amount and the stranger in the right words
320 const word = (x: bigint | string) => BigInt(x).toString(16).padStart(64, '0')
321 const found = layout.chunks.flatMap((c) => c.probes).find((p) => p.args === 'h0,a2,s')
322 const calls = layout.chunks.flatMap((c) => c.calls)
323 expect(calls[found!.index].data).toBe(`${SEL.rescue}${word(HOLDERS[1])}${word(STRANGER)}${word(BAL / 100n)}${word(STRANGER)}`)
324 })
325 test('base selectors keep only the base shapes (no per-probe reads)', () => {
326 const code = dispatcher(ERC20)
327 const { layout } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxRequests: 2 })
328 for (const p of layout.chunks.flatMap((c) => c.probes)) expect(p.holderRead).toBe(-1)
329 expect(layout.probes).toBe(2 + PROBE_SHAPES.length * ERC20.length)
330 })
331})
332
333describe('third-party drains are vetoed R3B whatever shape they take', () => {
334 test('an honest token passes', () => {
335 const o = judge(new FakeEvm(dispatcher(ERC20)))
336 expect(verdictOf(o)).toBe('pass')
337 if (o.verdict === 'error') throw new Error()
338 expect(o.probes).toBe(2 + PROBE_SHAPES.length * ERC20.length)
339 })
340 test('an honest upgrade that adds a benign new selector still passes', () => {
341 const version = sel('version()')
342 const o = judge(new FakeEvm(dispatcher([...ERC20, version]))) // version ignores its calldata
343 expect(verdictOf(o)).toBe('pass')
344 })
345 for (const [name, selector, behaviour] of [
346 ['open rescue(from, to, amount)', SEL.rescue, 'drain'],
347 ['A: rescueA(from, amount, to) (amount between the addresses)', SEL.rescueA, 'reorderA'],
348 ['B: sweepB(amount, from, to) (amount first)', SEL.sweepB, 'reorderB'],
349 ['D: rescue gated on the caller holding tokens', SEL.rescue, 'requireTokens'],
350 ['F: rescue gated on the caller holding ETH', SEL.rescue, 'requireEth'],
351 ] as const) {
352 test(`${name} is vetoed R3B (moved the holder's funds)`, () => {
353 const o = judge(new FakeEvm(dispatcher([...ERC20, selector]), [behaviour]))
354 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
355 if (o.verdict === 'error') throw new Error()
356 for (const r of ['R1_TOTAL_SUPPLY', 'R2_OWNER', 'R3_TRANSFER_CONSERVATION', 'R4_TRANSFER_LIVENESS']) expect(o.checks.find((c) => c.name === r)?.ok).toBe(true)
357 expect(o.culprits.some((c) => c.selector === selector && c.effects.includes('moved-others-funds') && c.args.startsWith('h'))).toBe(true)
358 })
359 }
360 test('C: rescueC(token, from, to, amount) with require(token == address(this)) is vetoed (the proxy-filled probe passes the guard)', () => {
361 const o = judge(new FakeEvm(dispatcher([...ERC20, SEL.rescueC]), ['tokenGuardC']))
362 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
363 if (o.verdict === 'error') throw new Error()
364 expect(o.culprits.some((c) => c.selector === SEL.rescueC && c.effects.includes('moved-others-funds') && c.args.endsWith(',p'))).toBe(true)
365 })
366 test('E: a drain that moves funds out with NO Transfer event is caught by the post-probe balance read', () => {
367 const o = judge(new FakeEvm(dispatcher([...ERC20, SEL.rescueE]), ['noLogE']))
368 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
369 if (o.verdict === 'error') throw new Error()
370 // no Transfer event was emitted, so the only effect is the holder's balance dropping after the probe
371 const c = o.culprits.find((x) => x.selector === SEL.rescueE)
372 expect(c?.effects).toContain('drained-holder')
373 expect(c?.effects).not.toContain('moved-others-funds')
374 expect(o.checks.find((k) => k.name === 'R3B_THIRD_PARTY_FUNDS')?.after).toContain('no event')
375 })
376 test('G: a transferFrom that waives the allowance check for a token-holding caller is caught (the stranger is funded)', () => {
377 // no new selector: the R3B transferFrom runs from the funded stranger, so the allowance-skip branch fires
378 const code = dispatcher(ERC20)
379 const o = judge(new FakeEvm(code, ['allowanceSkip']))
380 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
381 if (o.verdict === 'error') throw new Error()
382 expect(o.checks.find((c) => c.name === 'R3B_THIRD_PARTY_FUNDS')?.after).toContain('pulled')
383 // without a funded stranger the attack would be missed (shown here for contrast)
384 const missed = judge(new FakeEvm(dispatcher(ERC20), ['allowanceSkip']), { funder: null, fundAmount: 0n })
385 expect(verdictOf(missed)).toBe('pass')
386 })
387 test("a holder's tokens taken and given back in the same call still veto (probe effects count, not final balances)", () => {
388 const o = judge(new FakeEvm(dispatcher([...ERC20, SEL.borrow]), ['borrow']))
389 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
390 if (o.verdict === 'error') throw new Error()
391 expect(o.culprits.some((c) => c.selector === SEL.borrow && c.effects.includes('moved-others-funds'))).toBe(true)
392 })
393 test('a mint that is burned back in the same call vetoes R3B (supply effect) although totalSupply is unchanged', () => {
394 const o = judge(new FakeEvm(dispatcher([...ERC20, SEL.flashMint]), ['flashMint']))
395 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
396 if (o.verdict === 'error') throw new Error()
397 expect(o.checks.find((c) => c.name === 'R1_TOTAL_SUPPLY')?.ok).toBe(true)
398 expect(o.culprits.some((c) => c.selector === SEL.flashMint && c.effects.includes('supply'))).toBe(true)
399 })
400 test('a zero-value Transfer moves nothing and is not an effect', () => {
401 expect(verdictOf(judge(new FakeEvm(dispatcher([...ERC20, SEL.poke]), ['zeroTransfer'])))).toBe('pass')
402 })
403 test('an open mint is still R1 (R1 outranks R3B)', () => {
404 expect(verdictOf(judge(new FakeEvm(dispatcher([...ERC20, SEL.mint]), ['openMint'])))).toBe('R1_TOTAL_SUPPLY')
405 })
406})
407
408describe('R9 against a fixed allowlist', () => {
409 const withBasefee = dispatcher(ERC20, '4850') // BASEFEE POP
410 test("BASEFEE vetoes R9; the proxy's current implementation is never read", () => {
411 const evm = new FakeEvm(withBasefee)
412 const o = judge(evm)
413 expect(verdictOf(o)).toBe('R9_NEW_OPCODES')
414 // only eth_getCode of the new implementation and eth_simulateV1: no eth_getStorageAt, no code of the current one
415 expect(evm.calls.flat().map((c) => c.method)).toEqual(['eth_getCode', 'eth_simulateV1'])
416 expect(evm.calls[0][0].params[0]).toBe(IMPL)
417 })
418 test('an allowlisted opcode passes', () => expect(verdictOf(judge(new FakeEvm(withBasefee), { allowedOpcodes: ['BASEFEE'] }))).toBe('pass'))
419 test('an approved codehash passes, and the approval covers that codehash only', () => {
420 const o = judge(new FakeEvm(withBasefee), { codehashApproved: true })
421 expect(verdictOf(o)).toBe('pass')
422 if (o.verdict === 'error') throw new Error()
423 expect(o.checks.find((c) => c.name === 'R9_NEW_OPCODES')).toEqual({ name: 'R9_NEW_OPCODES', before: 'allowed: none', after: 'found: BASEFEE (codehash approved)', ok: true })
424 // a later upgrade with the same opcode and a different codehash: no approval for it, so R9 again
425 expect(verdictOf(judge(new FakeEvm(dispatcher([...ERC20, sel('v3()')], '4850'))))).toBe('R9_NEW_OPCODES')
426 })
427 test('only the opcodes the allowlist lacks are reported', () => {
428 expect(r9Flagged(dispatcher(ERC20, '48504250'), ['BASEFEE'])).toEqual(['TIMESTAMP'])
429 const o = judge(new FakeEvm(dispatcher(ERC20, '48504250')), { allowedOpcodes: ['BASEFEE'] })
430 expect(verdictOf(o)).toBe('R9_NEW_OPCODES')
431 if (o.verdict === 'error') throw new Error()
432 expect(o.flaggedOps).toEqual(['TIMESTAMP'])
433 })
434 test('the config accepts only R9 opcode names, once each', () => {
435 const base = {
436 chainSelectorName: 'ethereum-testnet-sepolia',
437 chainId: 11155111,
438 airlockAdmin: '0x1111111111111111111111111111111111111111',
439 receiver: '0x2222222222222222222222222222222222222222',
440 proxy: '0x3333333333333333333333333333333333333333',
441 holders: ['0x4444444444444444444444444444444444444444'],
442 rpcs: [{ url: 'https://a.example', label: 'a' }],
443 minAgree: 1,
444 }
445 expect(configSchema.parse(base).allowedOpcodes).toEqual([])
446 expect(configSchema.safeParse({ ...base, allowedOpcodes: ['STATICCALL', 'TIMESTAMP'] }).success).toBe(true)
447 expect(configSchema.safeParse({ ...base, allowedOpcodes: ['SSTORE'] }).success).toBe(false)
448 expect(configSchema.safeParse({ ...base, allowedOpcodes: ['GAS', 'GAS'] }).success).toBe(false)
449 })
450})
451
452describe('probe budget: split requests, truncation and approval', () => {
453 test('a base-only token (no new selector) fits in one request at the default maxCalls', () => {
454 const code = dispatcher([...ERC20, ...BASE_FILLERS]) // 14 base selectors, no placement probes
455 const evm = new FakeEvm(code)
456 const o = judge(evm)
457 expect(verdictOf(o)).toBe('pass')
458 if (o.verdict === 'error') throw new Error()
459 expect(o.requests).toBe(1)
460 expect(evm.calls.length).toBe(2)
461 })
462 test('a contract with a new selector is split into two requests, each under the call and size limits, and passes', () => {
463 const code = dispatcher([...ERC20, sel('version()')]) // one new (benign) selector -> placement probes
464 const evm = new FakeEvm(code)
465 const o = judge(evm, { maxCalls: 90 })
466 expect(verdictOf(o)).toBe('pass')
467 if (o.verdict === 'error') throw new Error()
468 expect(o.requests).toBe(2)
469 expect(evm.calls.length).toBe(3) // eth_getCode + 2 x eth_simulateV1: 1 + simRequests HTTP calls
470 const { layout, requests } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxCalls: 90, maxRequests: 2 })
471 requests.forEach((params, k) => {
472 const body = rpcBody('eth_simulateV1', params)
473 expect(body.length).toBe(layout.chunks[k].bytes)
474 expect(body.length).toBeLessThanOrEqual(SIM_REQUEST_BYTES)
475 expect(utf8ToBase64(body).length).toBeLessThanOrEqual(LIMITS.httpRequestBytes)
476 expect(layout.chunks[k].calls.length).toBeLessThanOrEqual(90)
477 })
478 // the second request carries no holder transfer: it funds the stranger again, then reads around its probes
479 expect(layout.chunks[1].transfer).toBe(-1)
480 expect(layout.chunks[1].r0).toBeNull()
481 expect(layout.chunks[1].fund).toBe(0)
482 })
483 test('the byte limit closes a request before maxCalls does when probes are large', () => {
484 const code = dispatcher([...ERC20, ...filler(44)])
485 const { layout } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxCalls: 400, maxRequests: 4, maxBytes: 40_000 })
486 for (const c of layout.chunks) expect(c.bytes).toBeLessThanOrEqual(40_000)
487 expect(layout.chunks.length).toBeGreaterThan(1)
488 })
489 test('more probes than fit: R6 without simulating, unless the codehash is approved', () => {
490 const code = dispatcher([...ERC20, ...filler(74)]) // 74 new selectors: placement explodes past the budget
491 const evm = new FakeEvm(code)
492 const o = judge(evm)
493 expect(verdictOf(o)).toBe('R6_SIM_FAILURE')
494 expect(evm.calls.length).toBe(1) // eth_getCode only
495 const approved = new FakeEvm(code)
496 const a = judge(approved, { codehashApproved: true })
497 expect(verdictOf(a)).toBe('pass')
498 if (a.verdict === 'error') throw new Error()
499 expect(a.checks.find((c) => c.name === 'R6_SIM_FAILURE')?.ok).toBe(true)
500 expect(approved.calls.length).toBe(3)
501 })
502 test('an approved codehash does not excuse a real drain: rescue still vetoes R3B', () => {
503 // the codehash approval only excuses probe truncation (R6); every other rule still runs on the probes that fit
504 const o = judge(new FakeEvm(dispatcher([...ERC20, SEL.rescue]), ['drain']), { codehashApproved: true })
505 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
506 })
507 test('a no-event drain whose probe lands in a later request is still caught (each request starts from the proposal block)', () => {
508 // one new selector, small requests so its placement probes span both requests
509 const code = dispatcher([...ERC20, ...BASE_FILLERS, SEL.rescueE])
510 const over = { maxCalls: 80, simRequests: 4 }
511 const o = judge(new FakeEvm(code, ['noLogE']), over)
512 expect(verdictOf(o)).toBe('R3B_THIRD_PARTY_FUNDS')
513 const { layout } = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxCalls: 80, maxRequests: 4 })
514 expect(layout.truncated).toBe(false)
515 expect(layout.chunks.length).toBeGreaterThan(1)
516 })
517 test('identical requests on every build (DON nodes must agree byte for byte)', () => {
518 const code = dispatcher([...ERC20, ...filler(10)])
519 const a = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxRequests: 2 })
520 const b = buildSimulation({ ...ctxFor(code), newCode: code, holder: HOLDERS[1], maxRequests: 2 })
521 expect(JSON.stringify(a.requests)).toBe(JSON.stringify(b.requests))
522 })
523 test('four providers stay within the HTTP call limit; a fifth needs simRequests <= 2', () => {
524 const base = {
525 chainSelectorName: 'ethereum-testnet-sepolia',
526 chainId: 11155111,
527 airlockAdmin: '0x1111111111111111111111111111111111111111',
528 receiver: '0x2222222222222222222222222222222222222222',
529 proxy: '0x3333333333333333333333333333333333333333',
530 holders: ['0x4444444444444444444444444444444444444444'],
531 minAgree: 2,
532 }
533 const rpcs = (n: number) => Array.from({ length: n }, (_, i) => ({ url: `https://${i}.example`, label: `p${i}` }))
534 expect(configSchema.parse({ ...base, rpcs: rpcs(4) }).simRequests).toBe(2)
535 expect(configSchema.safeParse({ ...base, rpcs: rpcs(5), simRequests: 2 }).success).toBe(true)
536 expect(configSchema.safeParse({ ...base, rpcs: rpcs(4), simRequests: 3 }).success).toBe(false)
537 expect(configSchema.safeParse({ ...base, rpcs: rpcs(3), simRequests: 4 }).success).toBe(true)
538 // the providers really make 1 + requests calls each
539 const evm = new FakeEvm(dispatcher([...ERC20, ...BASE_FILLERS]))
540 const { judgement } = simulateUpgrade(evm.rpc, rpcs(4), 2, ctxFor(evm.code))
541 expect(judgement.verdict).toBe('pass')
542 expect(evm.calls.length).toBe(4 * 2) // one new-selector-free contract -> 1 request each
543 expect(evm.calls.length).toBeLessThanOrEqual(LIMITS.httpCalls)
544 })
545 test('providers that see different code fail closed (R6)', () => {
546 const honest = new FakeEvm(dispatcher(ERC20))
547 const evil = new FakeEvm(dispatcher(ERC20), ['drain'])
548 evil.code = dispatcher([...ERC20, SEL.rescue])
549 const ctx = ctxFor(honest.code)
550 const outs = [judgeOnProvider(honest.rpc, 'a', 'a', ctx), judgeOnProvider(evil.rpc, 'b', 'b', ctx), judgeOnProvider(honest.rpc, 'c', 'c', ctx)]
551 expect(verdictOf(outs[1])).toBe('R6_SIM_FAILURE') // codehash mismatch
552 expect(quorum(outs, 2).rule).toBe('R6_SIM_FAILURE')
553 })
554})
555
556// ---------------------------------------------------------------- bytecode walker and the CBOR metadata tail
557const hex = (...parts: string[]) => `0x${parts.join('')}` as Hex
558const IPFS_KEY = '6469706673' // text(4) "ipfs"
559const SOLC_KEY = '64736f6c63' // text(4) "solc"
560/** A hash full of opcode bytes the walker must not see, with no 0x5b (JUMPDEST) in it. */
561const HASH34 = `1220${'4248f1f4ff'.repeat(6)}4344`
562const tailOf = (body: string) => body + (body.length / 2).toString(16).padStart(4, '0')
563const ipfsTail = (hash = HASH34) => tailOf(`a2${IPFS_KEY}5822${hash}${SOLC_KEY}43000825`)
564const PREFIX = '6080604052348015600e575f5ffd5b5f80fd5bfe' // ends in INVALID
565
566describe('bytecode: the metadata tail is skipped only when it is exact solc CBOR and unreachable', () => {
567 test('a real solc tail after INVALID is skipped (its hash bytes are not opcodes)', () => {
568 // the tail of DemoBTC as compiled by solc 0.8.37
569 const real = 'a2646970667358221220ae8a2104f53a97446523ff4972dad12b64598bce0166a857e37cb50aadd8c0c164736f6c6343000825'
570 const code = hex(PREFIX, tailOf(real))
571 expect(solcMetadataStart(codeBytes(code))).toBe(PREFIX.length / 2)
572 expect(dangerousOpcodes(code)).toEqual([])
573 expect(dangerousOpcodes(hex(PREFIX, ipfsTail()))).toEqual([])
574 })
575 test('other solc formats: bzzr0, bzzr1, metadata-hash none, experimental, a prerelease version string', () => {
576 const b32 = '4248f1f4'.repeat(8)
577 const prerelease = Buffer.from('0.8.31-develop.2025.1.1').toString('hex')
578 for (const body of [
579 `a1${'65627a7a7230'}5820${b32}`, // {bzzr0} (0.4.x)
580 `a2${'65627a7a7231'}5820${b32}${SOLC_KEY}43000511`, // {bzzr1, solc}
581 `a1${SOLC_KEY}43000825`, // {solc}
582 `a3${IPFS_KEY}5822${HASH34}${'6c6578706572696d656e74616c'}f5${SOLC_KEY}43000825`, // {ipfs, experimental: true, solc}
583 `a2${IPFS_KEY}5822${HASH34}${SOLC_KEY}77${prerelease}`, // {ipfs, solc: "0.8.31-develop..."}
584 ]) {
585 const code = hex(PREFIX, tailOf(body))
586 expect(solcMetadataStart(codeBytes(code))).toBe(PREFIX.length / 2)
587 expect(dangerousOpcodes(code)).toEqual([])
588 }
589 })
590 test('anything else is scanned as code (fail closed)', () => {
591 const body = `a2${IPFS_KEY}5822${HASH34}${SOLC_KEY}43000825`
592 const len = body.length / 2
593 const cases = {
594 'declared length one too long': hex(PREFIX, body, (len + 1).toString(16).padStart(4, '0')),
595 'declared length one too short': hex(PREFIX, body, (len - 1).toString(16).padStart(4, '0')),
596 'unknown key': hex(PREFIX, tailOf(body.replace(IPFS_KEY, '6469706679'))),
597 'duplicate key': hex(PREFIX, tailOf(`a2${SOLC_KEY}43000825${SOLC_KEY}43000825${HASH34}`)),
598 'a map of 5 entries': hex(PREFIX, tailOf(`a5${body.slice(2)}`)),
599 'not a map': hex(PREFIX, tailOf(`84${body.slice(2)}`)),
600 'ipfs hash of the wrong length': hex(PREFIX, tailOf(`a2${IPFS_KEY}5821${HASH34.slice(2)}${SOLC_KEY}43000825`)),
601 'solc version of the wrong type': hex(PREFIX, tailOf(`a2${IPFS_KEY}5822${HASH34}${SOLC_KEY}44000825ff`)),
602 'experimental that is not a boolean': hex(PREFIX, tailOf(`a3${IPFS_KEY}5822${HASH34}${'6c6578706572696d656e74616c'}01${SOLC_KEY}43000825`)),
603 }
604 for (const [name, code] of Object.entries(cases)) {
605 expect([name, codeLayout(codeBytes(code)).end]).toEqual([name, codeBytes(code).length])
606 expect([name, dangerousOpcodes(code).length > 0]).toEqual([name, true])
607 }
608 })
609 test('the attack the old walker allowed: a trailing length that hides reachable code and a selector', () => {
610 // dispatcher -> JUMPDEST BASEFEE ... a hidden dispatcher entry (PUSH4 sel DUP2 EQ PUSH2 dest JUMPI) STOP, then
611 // 0xa2 and a 2-byte "length" that claims all of it is metadata
612 const hidden = sel('steal()')
613 const body = `a25b485063${hidden.slice(2)}81146100005700`
614 const code = hex(`63${SEL.transfer.slice(2)}811461000057`, 'fe', tailOf(body))
615 expect(dangerousOpcodes(code)).toEqual(['BASEFEE'])
616 expect(extractSelectors(code)).toContain(hidden)
617 })
618 test('code that falls through into the tail makes it code', () => {
619 // the last instruction before the tail is POP, not a halting opcode
620 const code = hex('6001', '50', ipfsTail())
621 expect(codeLayout(codeBytes(code)).end).toBe(codeBytes(code).length)
622 expect(dangerousOpcodes(code)).toContain('TIMESTAMP')
623 })
624 test('a PUSH whose data runs into the tail makes it code', () => {
625 const code = hex('6000fe', '61', ipfsTail()) // PUSH2 takes the tail's first bytes as data
626 expect(codeLayout(codeBytes(code)).end).toBe(codeBytes(code).length)
627 })
628 test('a reachable JUMPDEST inside the hash is followed until execution halts', () => {
629 // hash: 1220, JUMPDEST BASEFEE STOP TIMESTAMP, padding: BASEFEE is reachable, TIMESTAMP (after STOP) is not
630 const hash = `12205b480042${'01'.repeat(28)}`
631 const code = hex(PREFIX, ipfsTail(hash))
632 const layout = codeLayout(codeBytes(code))
633 expect(layout.end).toBe(PREFIX.length / 2)
634 expect(layout.tailEntries.length).toBe(1)
635 expect(dangerousOpcodes(code)).toEqual(['BASEFEE'])
636 })
637 test('a 0x5b inside PUSH data of the tail is not a JUMPDEST', () => {
638 // 0x61 = PUSH2 swallows 5b 48: no entry point
639 const hash = `1220615b48${'01'.repeat(29)}`
640 const code = hex(PREFIX, ipfsTail(hash))
641 expect(codeLayout(codeBytes(code)).tailEntries).toEqual([])
642 expect(dangerousOpcodes(code)).toEqual([])
643 })
644 test('code without any tail is scanned to the end', () => {
645 expect(dangerousOpcodes('0x60004200')).toEqual(['TIMESTAMP'])
646 expect(codeLayout(codeBytes('0x6000'))).toEqual({ end: 2, tailEntries: [] })
647 })
648})