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". |
| 34 | import { |
| 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' |
| 46 | import { TOKEN_ABI } from './abi' |
| 47 | import { extractSelectors } from './bytecode' |
| 48 | import { LIMITS } from './chain' |
| 49 | import type { RuleId } from './rules' |
| 50 | |
| 51 | // ERC1967Utils.IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) |
| 52 | export 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 | */ |
| 61 | export const SIM_REQUEST_BYTES = 88_000 |
| 62 | if (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. */ |
| 65 | export const STRANGER_ETH = 10n ** 18n |
| 66 | /** Placement probes try the holder in each of the first four argument slots. */ |
| 67 | export const ARG_SLOTS = 4 |
| 68 | |
| 69 | const BIG = 2n ** 128n |
| 70 | const 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. */ |
| 73 | const 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 | ] |
| 107 | export 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). */ |
| 110 | export const newSelectors = (selectors: string[]): string[] => selectors.filter((s) => !BASE_SELECTORS.has(s)) |
| 111 | |
| 112 | /** Deterministic per-proposal addresses (identical on every DON node). */ |
| 113 | export 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 | */ |
| 129 | export const PROBE_SHAPES = ['addr', 'addr,big', 'none', 'big', 'wide'] as const |
| 130 | export type ProbeShape = (typeof PROBE_SHAPES)[number] |
| 131 | export const PROBES_PER_SELECTOR = PROBE_SHAPES.length |
| 132 | |
| 133 | export 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 | */ |
| 153 | export 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`). */ |
| 169 | export 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 | |
| 180 | type 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 | */ |
| 187 | export 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 | |
| 198 | type ReadFn = 'totalSupply' | 'owner' | 'balanceOf' |
| 199 | type Call = { from: Address; to: Address; data: string; gas: Hex; _tag: string; _fn?: ReadFn | 'transfer' | 'transferFrom' } |
| 200 | type 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). */ |
| 202 | type 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). */ |
| 205 | export 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 | } |
| 219 | export 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 | |
| 235 | export 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). */ |
| 264 | export const rpcBody = (method: string, params: unknown[]) => JSON.stringify([{ jsonrpc: '2.0', id: 0, method, params }]) |
| 265 | const strip = ({ _tag, _fn, ...c }: Call) => c |
| 266 | |
| 267 | /** Build the eth_simulateV1 params of every request and the layout needed to read their results. */ |
| 268 | export 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 | |
| 405 | export class ViewRevert extends Error {} |
| 406 | |
| 407 | export type Check = { name: RuleId; before: string; after: string; ok: boolean } |
| 408 | export type Culprit = { selector: string; args: string; effects: string[] } |
| 409 | export type Evaluation = { |
| 410 | verdict: 'veto' | 'pass' |
| 411 | rule: RuleId | null |
| 412 | checks: Check[] |
| 413 | culprits: Culprit[] |
| 414 | probes: number |
| 415 | } |
| 416 | |
| 417 | type SimCall = { status?: string | number; returnData?: Hex; logs?: { data: Hex; topics: Hex[] }[]; error?: { message?: string } } |
| 418 | type 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 | */ |
| 427 | export 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 | */ |
| 448 | export 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 | } |