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 / simulate.ts
cre/veto/simulate.ts567 lines · 27230 bytes
1// Attack simulation: the proxy's ERC-1967 implementation slot is overridden with the proposed implementation and
2// the stranger is made to look like a real user, then one simulated block per eth_simulateV1 request. In the first
3// request the calls run in this order:
4// fund stranger (a transfer from a second holder to the stranger) -> baseline reads -> holder transfer (R3/R4) ->
5// reads -> stranger transferFrom without allowance (R3B) -> stranger probes (reads after every placement probe) ->
6// final reads (totalSupply, owner, every watched balance).
7// Each later request (only when the probes do not all fit in one) starts again from the proposal block: fund
8// stranger -> reads -> more probes -> reads. The stranger is funded in every request because every request is
9// independent (each starts from the proposal block), and the funding transfer comes from a *second* holder so the
10// test holder's R3 accounting stays exactly -amount, and before the baseline reads so the conservation check sees
11// the already-funded balances.
12//
13// Making the stranger look real matters because malicious implementations gate the drain on it:
14// - a token balance (the funding transfer) defeats `require(balanceOf(msg.sender) != 0)` and the transferFrom that
15// skips its allowance check when the caller already holds tokens;
16// - ETH (a stateOverrides balance) defeats `require(msg.sender.balance != 0)`.
17//
18// Probes. Every selector the dispatcher compares against gets the base shapes (the stranger, the stranger and a
19// large number, a large number, none, a wide mix) plus empty and unknown-selector calldata. Every selector that is
20// NEW relative to the standard token/ownable/proxy interface (BASE_SELECTORS) also gets placement probes: the funded
21// holder in each of the first four argument slots, the test amount in each other slot, and the remaining slots
22// filled once with the stranger and once with the proxy address. That covers a drain however its arguments are
23// ordered (rescue(from, amount, to), sweep(amount, from, to), ...) and a `require(token == address(this))` guard (the
24// proxy-filled variant passes it). Right after each placement probe the holder's and the stranger's balances are
25// read, so a drain that moves the holder's tokens out and a later probe moves them back in the same request — with no
26// Transfer event — is still seen by the drop between the two reads. `amount` is the test amount (1% of the holder's
27// balance), small enough that a function moving the holder's tokens does not revert for lack of balance.
28//
29// Every DON node must build byte-identical requests and reach the identical verdict, so nothing here is random:
30// - the stranger and receiver addresses are derived from the proposal (chain, AirlockAdmin, id, block hash);
31// - the "unknown selector" fallback probe is derived the same way;
32// - the funder, the funding amount and the stranger's ETH override are passed in, read at the proposal block;
33// - the simulation is pinned to the proposal's block, never "latest".
34import {
35 type Address,
36 decodeEventLog,
37 decodeFunctionResult,
38 encodeAbiParameters,
39 encodeFunctionData,
40 type Hex,
41 keccak256,
42 numberToHex,
43 pad,
44 stringToBytes,
45} from 'viem'
46import { TOKEN_ABI } from './abi'
47import { extractSelectors } from './bytecode'
48import { LIMITS } from './chain'
49import type { RuleId } from './rules'
50
51// ERC1967Utils.IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
52export const IMPLEMENTATION_SLOT = numberToHex(BigInt(keccak256(stringToBytes('eip1967.proxy.implementation'))) - 1n, {
53 size: 32,
54})
55
56/**
57 * Largest JSON body of one eth_simulateV1 request, in bytes. The HTTP capability's request size limit is 120 kb
58 * (LIMITS.httpRequestBytes); the body travels base64-encoded in the capability's JSON form (4/3 larger), so the
59 * plain body is kept at or below 88,000 bytes: 117,336 once encoded.
60 */
61export const SIM_REQUEST_BYTES = 88_000
62if (Math.ceil(SIM_REQUEST_BYTES / 3) * 4 > LIMITS.httpRequestBytes) throw new Error('SIM_REQUEST_BYTES does not fit the HTTP request limit')
63
64/** The stranger's simulated ETH balance (stateOverrides), so a `require(msg.sender.balance != 0)` drain is reached. */
65export const STRANGER_ETH = 10n ** 18n
66/** Placement probes try the holder in each of the first four argument slots. */
67export const ARG_SLOTS = 4
68
69const BIG = 2n ** 128n
70const word = (x: bigint | Hex) => pad(typeof x === 'bigint' ? numberToHex(x) : x, { size: 32 }).slice(2)
71
72/** Selectors of the standard token / ownable / proxy / initializer interface: a selector outside this set is NEW. */
73const BASE_SIGNATURES = [
74 // ERC-20 and metadata
75 'totalSupply()',
76 'balanceOf(address)',
77 'transfer(address,uint256)',
78 'allowance(address,address)',
79 'approve(address,uint256)',
80 'transferFrom(address,address,uint256)',
81 'name()',
82 'symbol()',
83 'decimals()',
84 // ERC-2612 permit
85 'nonces(address)',
86 'DOMAIN_SEPARATOR()',
87 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)',
88 // Ownable / Ownable2Step
89 'owner()',
90 'renounceOwnership()',
91 'transferOwnership(address)',
92 'pendingOwner()',
93 'acceptOwnership()',
94 // common mint / burn
95 'mint(address,uint256)',
96 'burn(uint256)',
97 'burnFrom(address,uint256)',
98 // ERC-1822 / UUPS / proxy
99 'upgradeToAndCall(address,bytes)',
100 'proxiableUUID()',
101 'implementation()',
102 // initializers
103 'initialize()',
104 'initialize(address)',
105 'initialize(address,address[],uint256[])',
106]
107export const BASE_SELECTORS: ReadonlySet<string> = new Set(BASE_SIGNATURES.map((s) => keccak256(stringToBytes(s)).slice(0, 10)))
108
109/** The selectors of the new code that are not part of the standard interface (the ones a drain would hide behind). */
110export const newSelectors = (selectors: string[]): string[] => selectors.filter((s) => !BASE_SELECTORS.has(s))
111
112/** Deterministic per-proposal addresses (identical on every DON node). */
113export function proposalActors(chainId: bigint, airlockAdmin: Address, id: bigint, blockHash: Hex) {
114 const derive = (tag: string) =>
115 `0x${keccak256(
116 encodeAbiParameters(
117 [{ type: 'string' }, { type: 'uint256' }, { type: 'address' }, { type: 'uint256' }, { type: 'bytes32' }],
118 [tag, chainId, airlockAdmin, id, blockHash],
119 ),
120 ).slice(-40)}` as Address
121 return { stranger: derive('upgrade-airlock/stranger'), receiver: derive('upgrade-airlock/receiver') }
122}
123
124/**
125 * Base argument shapes of a selector probe, in probe order. These run against every selector and catch a drain that
126 * shows itself through a Transfer/OwnershipTransferred event. Placement probes (below) add the argument-order and
127 * no-event coverage for NEW selectors.
128 */
129export const PROBE_SHAPES = ['addr', 'addr,big', 'none', 'big', 'wide'] as const
130export type ProbeShape = (typeof PROBE_SHAPES)[number]
131export const PROBES_PER_SELECTOR = PROBE_SHAPES.length
132
133export function probeData(shape: ProbeShape, selector: string, stranger: Address): string {
134 switch (shape) {
135 case 'none':
136 return selector
137 case 'addr':
138 return selector + word(stranger)
139 case 'addr,big':
140 return selector + word(stranger) + word(BIG)
141 case 'big':
142 return selector + word(BIG)
143 case 'wide':
144 return selector + word(stranger) + word(BIG) + word(BIG) + word(stranger) + word(0n) + word(0n) + word(0n) + word(0n)
145 }
146}
147
148/**
149 * Placement probes of one NEW selector: the holder in each of the first ARG_SLOTS argument slots, the test amount in
150 * each of the other slots, and every remaining slot filled once with the stranger and once with the proxy. A probe is
151 * `holder in slot i, amount in slot j, rest = fill`; the label is `h<i>,a<j>,<s|p>`.
152 */
153export function placementProbes(selector: string, stranger: Address, holder: Address, proxy: Address, amount: bigint) {
154 const out: { args: string; data: string }[] = []
155 for (let i = 0; i < ARG_SLOTS; i++) {
156 for (let j = 0; j < ARG_SLOTS; j++) {
157 if (j === i) continue
158 for (const [name, fill] of [['s', stranger] as const, ['p', proxy] as const]) {
159 let data = selector
160 for (let k = 0; k < ARG_SLOTS; k++) data += k === i ? word(holder) : k === j ? word(amount) : word(fill)
161 out.push({ args: `h${i},a${j},${name}`, data })
162 }
163 }
164 }
165 return out
166}
167
168/** Empty calldata and calldata with an unknown selector (derived from the stranger, never one of `selectors`). */
169export function fallbackCalldata(stranger: Address, selectors: string[]) {
170 let n = 0
171 let sel: string
172 do sel = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [stranger, BigInt(n++)])).slice(0, 10)
173 while (selectors.includes(sel))
174 return [
175 { args: 'empty', data: '0x' },
176 { args: 'random', data: sel + word(stranger) + word(BIG) + word(BIG) },
177 ]
178}
179
180type Probe = { selector: string; args: string; data: string; placement: boolean }
181/**
182 * Every probe, in the order they are packed into requests: the fallback probes, then the base shapes shape-by-shape
183 * over all selectors, then the placement probes selector-by-selector over the NEW selectors. When the probes do not
184 * all fit, what is left out is always from the tail (the placement probes of the last new selectors first), never a
185 * whole selector's base coverage.
186 */
187export function wantedProbes(selectors: string[], stranger: Address, holder: Address, proxy: Address, amount: bigint): Probe[] {
188 const base: Probe[] = [
189 ...fallbackCalldata(stranger, selectors).map((p) => ({ selector: 'fallback', ...p, placement: false })),
190 ...PROBE_SHAPES.flatMap((args) => selectors.map((selector) => ({ selector, args: args as string, data: probeData(args, selector, stranger), placement: false }))),
191 ]
192 const placed: Probe[] = newSelectors(selectors).flatMap((selector) =>
193 placementProbes(selector, stranger, holder, proxy, amount).map((p) => ({ selector, args: p.args, data: p.data, placement: true })),
194 )
195 return [...base, ...placed]
196}
197
198type ReadFn = 'totalSupply' | 'owner' | 'balanceOf'
199type Call = { from: Address; to: Address; data: string; gas: Hex; _tag: string; _fn?: ReadFn | 'transfer' | 'transferFrom' }
200type Reads = { totalSupply: number; owner: number; balances: number[] }
201/** One probe's index, plus the indices of the holder/stranger balance reads that follow a placement probe (else -1). */
202type ProbeRef = { index: number; selector: string; args: string; holderRead: number; strangerRead: number }
203/** One eth_simulateV1 request. `r0`, `transfer` and `transferFrom` exist in the first request only. `fund` is the
204 * stranger-funding transfer (-1 when there is no second holder to fund it from). */
205export type Chunk = {
206 calls: Call[]
207 fund: number
208 r0: Reads | null
209 transfer: number
210 transferFrom: number
211 /** reads right before the probes */
212 r1: Reads
213 probes: ProbeRef[]
214 /** reads after the probes */
215 r2: Reads
216 /** JSON body size of the request (what the HTTP capability sends) */
217 bytes: number
218}
219export type Layout = {
220 stranger: Address
221 receiver: Address
222 holder: Address
223 funder: Address | null
224 amount: bigint
225 watchList: Address[]
226 selectors: string[]
227 newSelectors: string[]
228 chunks: Chunk[]
229 /** probes over all chunks */
230 probes: number
231 truncated: boolean
232 wantedProbes: number
233}
234
235export type SimulationInput = {
236 proxy: Address
237 newImpl: Address
238 newCode: Hex
239 holder: Address
240 amount: bigint
241 /** A second holder (never the test holder) whose tokens fund the stranger; null when none has a balance. */
242 funder?: Address | null
243 /** How many tokens to move from the funder to the stranger (<= the funder's balance). */
244 fundAmount?: bigint
245 /** The stranger's simulated ETH balance (default STRANGER_ETH). */
246 strangerEth?: bigint
247 /** Every configured holder: all of them are watched, not only the one that makes the test transfer. */
248 holders: Address[]
249 watch: Address[]
250 stranger: Address
251 receiver: Address
252 block: bigint
253 callGas: number
254 readGas: number
255 /** Simulated calls per eth_simulateV1 request. */
256 maxCalls: number
257 /** eth_simulateV1 requests per provider. */
258 maxRequests: number
259 /** JSON body size limit per request (default SIM_REQUEST_BYTES). */
260 maxBytes?: number
261}
262
263/** The JSON body httpRpcBatch sends for one request (a batch of one). */
264export const rpcBody = (method: string, params: unknown[]) => JSON.stringify([{ jsonrpc: '2.0', id: 0, method, params }])
265const strip = ({ _tag, _fn, ...c }: Call) => c
266
267/** Build the eth_simulateV1 params of every request and the layout needed to read their results. */
268export function buildSimulation(input: SimulationInput): { requests: unknown[][]; layout: Layout } {
269 const { proxy, newImpl, newCode, holder, amount, stranger, receiver } = input
270 const lc = (a: string) => a.toLowerCase() as Address
271 const maxBytes = input.maxBytes ?? SIM_REQUEST_BYTES
272 const funder = input.funder ? lc(input.funder) : null
273 const fundAmount = input.fundAmount ?? 0n
274 const strangerEth = input.strangerEth ?? STRANGER_ETH
275 const doFund = funder !== null && funder !== lc(holder) && fundAmount > 0n
276 // Watched balances: configured watch addresses, every holder, the derived receiver, and the stranger (so a drain
277 // into the stranger and an out-and-back move with no event both show up in the balance reads).
278 const watchList = [...new Set([...input.watch.map(lc), ...input.holders.map(lc), lc(holder), lc(receiver), lc(stranger)])]
279 const stateOverrides = {
280 [proxy]: { stateDiff: { [IMPLEMENTATION_SLOT]: pad(lc(newImpl), { size: 32 }) } },
281 [lc(stranger)]: { balance: numberToHex(strangerEth) },
282 }
283 const blockTag = numberToHex(input.block)
284 const paramsOf = (calls: Call[]) => [{ blockStateCalls: [{ stateOverrides, calls: calls.map(strip) }] }, blockTag]
285 const emptyBytes = rpcBody('eth_simulateV1', paramsOf([])).length
286
287 const readCall = (fn: ReadFn, args: readonly unknown[], tag: string): Call => ({
288 from: stranger,
289 to: proxy,
290 data: encodeFunctionData({ abi: TOKEN_ABI, functionName: fn, args: args as never }),
291 gas: numberToHex(input.readGas),
292 _tag: tag,
293 _fn: fn,
294 })
295 const readSet = (phase: string): Call[] => [
296 readCall('totalSupply', [], `${phase}:totalSupply`),
297 readCall('owner', [], `${phase}:owner`),
298 ...watchList.map((a) => readCall('balanceOf', [a], `${phase}:balance`)),
299 ]
300 const holderRead = (): Call => readCall('balanceOf', [lc(holder)], 'probe:holderBalance')
301 const strangerRead = (): Call => readCall('balanceOf', [lc(stranger)], 'probe:strangerBalance')
302 const fundCall = (): Call => ({
303 from: funder as Address,
304 to: proxy,
305 data: encodeFunctionData({ abi: TOKEN_ABI, functionName: 'transfer', args: [stranger, fundAmount] }),
306 gas: numberToHex(input.callGas),
307 _tag: 'fund',
308 _fn: 'transfer',
309 })
310 const callBytes = (c: Call) => JSON.stringify(strip(c)).length
311 const finalReads = readSet('final')
312 const finalCount = finalReads.length
313 const finalBytes = finalReads.reduce((n, c) => n + callBytes(c) + 1, 0)
314
315 // A request under construction: its calls and their JSON size (commas included).
316 class Builder {
317 calls: Call[] = []
318 bytes = emptyBytes
319 push(c: Call) {
320 this.bytes += callBytes(c) + (this.calls.length ? 1 : 0)
321 this.calls.push(c)
322 return this.calls.length - 1
323 }
324 reads(phase: string): Reads {
325 const idx = readSet(phase).map((c) => this.push(c))
326 return { totalSupply: idx[0], owner: idx[1], balances: idx.slice(2) }
327 }
328 }
329
330 const selectors = extractSelectors(newCode)
331 const news = newSelectors(selectors)
332 const wanted = wantedProbes(selectors, lc(stranger), lc(holder), lc(proxy), amount)
333 const chunks: Chunk[] = []
334 let next = 0
335 const maxRequests = Math.max(1, Math.floor(input.maxRequests))
336 for (let k = 0; k < maxRequests && (k === 0 || next < wanted.length); k++) {
337 const b = new Builder()
338 let r0: Reads | null = null
339 let transfer = -1
340 let transferFrom = -1
341 const fund = doFund ? b.push(fundCall()) : -1
342 if (k === 0) {
343 r0 = b.reads('before')
344 transfer = b.push({
345 from: holder,
346 to: proxy,
347 data: encodeFunctionData({ abi: TOKEN_ABI, functionName: 'transfer', args: [receiver, amount] }),
348 gas: numberToHex(input.callGas),
349 _tag: 'transfer',
350 _fn: 'transfer',
351 })
352 }
353 const r1 = b.reads(k === 0 ? 'afterTransfer' : `chunk${k}:before`)
354 if (k === 0) {
355 // the R3B transferFrom runs from the now-funded stranger, so a token that waives its allowance check for a
356 // caller that holds tokens is caught here.
357 transferFrom = b.push({
358 from: stranger,
359 to: proxy,
360 data: encodeFunctionData({ abi: TOKEN_ABI, functionName: 'transferFrom', args: [holder, stranger, amount] }),
361 gas: numberToHex(input.callGas),
362 _tag: 'transferFrom',
363 _fn: 'transferFrom',
364 })
365 }
366 const probes: ProbeRef[] = []
367 while (next < wanted.length) {
368 const p = wanted[next]
369 const probeCall: Call = { from: stranger, to: proxy, data: p.data, gas: numberToHex(input.callGas), _tag: 'probe' }
370 // a placement probe is an atomic unit of probe + holder read + stranger read, never split across requests
371 const extra = p.placement ? [holderRead(), strangerRead()] : []
372 const addCalls = 1 + extra.length
373 const addBytes = callBytes(probeCall) + extra.reduce((n, c) => n + callBytes(c) + 1, 0) + 1
374 if (b.calls.length + addCalls + finalCount > input.maxCalls || b.bytes + addBytes + finalBytes > maxBytes) break
375 const index = b.push(probeCall)
376 const hr = p.placement ? b.push(extra[0]) : -1
377 const sr = p.placement ? b.push(extra[1]) : -1
378 probes.push({ index, selector: p.selector, args: p.args, holderRead: hr, strangerRead: sr })
379 next++
380 }
381 const r2 = b.reads(k === 0 ? 'final' : `chunk${k}:final`)
382 // a request that holds no probe (the budget is too small for even one) adds nothing: stop here
383 if (k > 0 && !probes.length) break
384 const bytes = rpcBody('eth_simulateV1', paramsOf(b.calls)).length
385 chunks.push({ calls: b.calls, fund, r0, transfer, transferFrom, r1, probes, r2, bytes })
386 }
387
388 const layout: Layout = {
389 stranger: lc(stranger),
390 receiver: lc(receiver),
391 holder: lc(holder),
392 funder: doFund ? funder : null,
393 amount,
394 watchList,
395 selectors,
396 newSelectors: news,
397 chunks,
398 probes: next,
399 truncated: next < wanted.length,
400 wantedProbes: wanted.length,
401 }
402 return { requests: chunks.map((c) => paramsOf(c.calls)), layout }
403}
404
405export class ViewRevert extends Error {}
406
407export type Check = { name: RuleId; before: string; after: string; ok: boolean }
408export type Culprit = { selector: string; args: string; effects: string[] }
409export type Evaluation = {
410 verdict: 'veto' | 'pass'
411 rule: RuleId | null
412 checks: Check[]
413 culprits: Culprit[]
414 probes: number
415}
416
417type SimCall = { status?: string | number; returnData?: Hex; logs?: { data: Hex; topics: Hex[] }[]; error?: { message?: string } }
418type Snap = { totalSupply: bigint; owner: string; balances: bigint[] }
419
420/**
421 * What one stranger probe did, from the logs of its own call: 'supply' (a Transfer from or to address 0),
422 * 'moved-others-funds' (a Transfer out of anyone but the stranger) or 'owner' (OwnershipTransferred). Transfers of
423 * zero move nothing and are ignored. Every effect vetoes (R3B unless R1/R2 already caught it): a stranger's call
424 * that mints and burns back, or that moves a holder's tokens and returns them, leaves the final reads unchanged
425 * but is still an attack.
426 */
427export function probeEffects(logs: SimCall['logs'], stranger: Address): string[] {
428 const effects = new Set<string>()
429 for (const lg of logs ?? []) {
430 try {
431 const ev = decodeEventLog({ abi: TOKEN_ABI, data: lg.data, topics: lg.topics as [Hex, ...Hex[]] })
432 if (ev.eventName === 'Transfer') {
433 const { from, to, value } = ev.args as { from: string; to: string; value: bigint }
434 if (value === 0n) continue
435 if (BigInt(from) === 0n || BigInt(to) === 0n) effects.add('supply')
436 if (BigInt(from) !== 0n && from.toLowerCase() !== stranger.toLowerCase()) effects.add('moved-others-funds')
437 }
438 if (ev.eventName === 'OwnershipTransferred') effects.add('owner')
439 } catch {}
440 }
441 return [...effects].sort()
442}
443
444/**
445 * Evaluate the eth_simulateV1 results (one per request, in request order) against R1-R4 and R3B.
446 * Throws ViewRevert (-> R6) if a read reverted, the stranger funding reverted, or a result has the wrong shape.
447 */
448export function evaluateSimulation(results: unknown[], layout: Layout): Evaluation {
449 if (!Array.isArray(results) || results.length !== layout.chunks.length) {
450 throw new ViewRevert(`expected ${layout.chunks.length} simulation result(s), got ${Array.isArray(results) ? results.length : typeof results}`)
451 }
452 const holderIdx = layout.watchList.indexOf(layout.holder)
453 const strangerIdx = layout.watchList.indexOf(layout.stranger)
454 const per = layout.chunks.map((chunk, k) => {
455 const result = results[k]
456 const block = Array.isArray(result) ? result[0] : result
457 const res = (block as { calls?: SimCall[] } | undefined)?.calls
458 if (!Array.isArray(res) || res.length !== chunk.calls.length) {
459 throw new ViewRevert(`unexpected simulate result shape (${res?.length} calls, expected ${chunk.calls.length})`)
460 }
461 const ok = (i: number) => res[i].status === '0x1' || res[i].status === 1 || res[i].status === '1'
462 const readVal = (i: number): unknown => {
463 if (!ok(i)) throw new ViewRevert(`view ${chunk.calls[i]._tag} reverted${res[i].error?.message ? `: ${res[i].error?.message}` : ''}`)
464 const fn = chunk.calls[i]._fn
465 if (!fn) throw new ViewRevert(`call ${i} is not a read`)
466 return decodeFunctionResult({ abi: TOKEN_ABI, functionName: fn, data: res[i].returnData ?? '0x' })
467 }
468 // A funding transfer that reverted means the stranger is not funded and the gated drains would be missed: fail
469 // closed (R6) rather than reach a false pass.
470 if (chunk.fund >= 0 && !ok(chunk.fund)) throw new ViewRevert(`stranger funding transfer reverted${res[chunk.fund].error?.message ? `: ${res[chunk.fund].error?.message}` : ''}`)
471 const snap = (r: Reads): Snap => ({
472 totalSupply: readVal(r.totalSupply) as bigint,
473 owner: (readVal(r.owner) as string).toLowerCase(),
474 balances: r.balances.map((i) => readVal(i) as bigint),
475 })
476 const s0 = chunk.r0 ? snap(chunk.r0) : null
477 const s1 = snap(chunk.r1)
478 const s2 = snap(chunk.r2)
479 // the holder/stranger balances right before this chunk's probes, the baseline for the per-probe drain check
480 const holderBefore = s1.balances[holderIdx]
481 const culprits: Culprit[] = []
482 for (const p of chunk.probes) {
483 if (!ok(p.index)) continue
484 const effects = new Set(probeEffects(res[p.index].logs, layout.stranger))
485 // a placement probe that moved the holder's tokens out — even with no Transfer event — leaves the holder's
486 // balance below its pre-probe value when read right after the probe
487 if (p.holderRead >= 0 && ok(p.holderRead) && (readVal(p.holderRead) as bigint) < holderBefore) effects.add('drained-holder')
488 if (effects.size) culprits.push({ selector: p.selector, args: p.args, effects: [...effects].sort() })
489 }
490 return { chunk, res, ok, s0, s1, s2, culprits }
491 })
492
493 // R4 and R3: the plain holder transfer of the first request.
494 const first = per[0]
495 const s0 = first.s0 as Snap
496 const t = first.res[first.chunk.transfer]
497 let transferOk = first.ok(first.chunk.transfer)
498 if (transferOk && t.returnData && t.returnData !== '0x') {
499 try {
500 transferOk = decodeFunctionResult({ abi: TOKEN_ABI, functionName: 'transfer', data: t.returnData }) === true
501 } catch {
502 transferOk = false
503 }
504 }
505 // R3: holder -X, receiver +X, everyone else on the watchlist unchanged, totalSupply unchanged. The stranger funding
506 // happens before the baseline reads, so the funder and the stranger are unchanged across the test transfer.
507 const X = layout.amount
508 const deltas = layout.watchList.map((a, k) => ({ a, d: first.s1.balances[k] - s0.balances[k] }))
509 const conservation =
510 transferOk &&
511 deltas.every(({ a, d }) => (a === layout.holder ? d === -X : a === layout.receiver ? d === X : d === 0n)) &&
512 first.s1.totalSupply === s0.totalSupply
513 const recvIdx = layout.watchList.indexOf(layout.receiver)
514
515 // R1 and R2 per request: the probes of a request must not change totalSupply, and the owner at its end must be the
516 // owner before the simulation.
517 const r1Bad = per.find((c) => c.s2.totalSupply !== c.s1.totalSupply)
518 const r2Bad = per.find((c) => c.s2.owner !== s0.owner)
519 const r1Shown = r1Bad ?? first
520 const r2Shown = r2Bad ?? first
521
522 const checks: Check[] = [
523 { name: 'R1_TOTAL_SUPPLY', before: String(r1Shown.s1.totalSupply), after: String(r1Shown.s2.totalSupply), ok: !r1Bad },
524 { name: 'R2_OWNER', before: s0.owner, after: r2Shown.s2.owner, ok: !r2Bad },
525 {
526 name: 'R3_TRANSFER_CONSERVATION',
527 before: `receiver+${X}`,
528 after: `receiver+${first.s1.balances[recvIdx] - s0.balances[recvIdx]}`,
529 ok: conservation,
530 },
531 { name: 'R4_TRANSFER_LIVENESS', before: 'transfer', after: transferOk ? 'success' : 'reverted', ok: transferOk },
532 ]
533
534 // R3B: a stranger without allowance must not pull the holder's funds, and no stranger probe may mint, burn, move
535 // anyone else's tokens or ownership (from its own logs, or from the holder's balance dropping after a placement
536 // probe) even if a later call undoes it. The stranger is funded first, so a drain gated on the caller holding
537 // tokens or ETH is reached.
538 let pulled = false
539 const tf = first.res[first.chunk.transferFrom]
540 if (first.ok(first.chunk.transferFrom)) {
541 pulled = true
542 if (tf.returnData && tf.returnData !== '0x') {
543 try {
544 pulled = decodeFunctionResult({ abi: TOKEN_ABI, functionName: 'transferFrom', data: tf.returnData }) !== false
545 } catch {
546 pulled = true
547 }
548 }
549 }
550 const culprits = per.flatMap((c) => c.culprits)
551 const drained = culprits.filter((c) => c.effects.includes('drained-holder')).length
552 const logged = culprits.filter((c) => !c.effects.includes('drained-holder') || c.effects.length > 1).length
553 checks.push({
554 name: 'R3B_THIRD_PARTY_FUNDS',
555 before: 'transferFrom(holder, stranger) without allowance; funded-stranger probes',
556 after: pulled
557 ? 'transferFrom pulled the holder funds'
558 : [logged && `${logged} probe(s) with effects`, drained && `${drained} probe(s) drained the holder with no event`].filter(Boolean).join(', ') ||
559 'reverted, holder balance held, no probe effects',
560 ok: !pulled && culprits.length === 0,
561 })
562
563 // Priority when several invariants break: liveness, conservation, owner, supply, third-party funds.
564 const order: RuleId[] = ['R4_TRANSFER_LIVENESS', 'R3_TRANSFER_CONSERVATION', 'R2_OWNER', 'R1_TOTAL_SUPPLY', 'R3B_THIRD_PARTY_FUNDS']
565 const failed = order.find((n) => !checks.find((c) => c.name === n)?.ok) ?? null
566 return { verdict: failed ? 'veto' : 'pass', rule: failed, checks, culprits, probes: layout.probes }
567}