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. |
| 3 | import { describe, expect, test } from 'bun:test' |
| 4 | import { type Address, encodeAbiParameters, encodeEventTopics, encodeFunctionResult, type Hex, keccak256, stringToBytes } from 'viem' |
| 5 | import { TOKEN_ABI } from '../abi' |
| 6 | import { codeBytes, codeLayout, dangerousOpcodes, extractSelectors, solcMetadataStart } from '../bytecode' |
| 7 | import { LIMITS } from '../chain' |
| 8 | import { configSchema } from '../config' |
| 9 | import { judgeOnProvider, type ProviderOutcome, quorum, type RpcBatch, type RpcCall, r9Flagged, simulateUpgrade, type UpgradeContext } from '../judge' |
| 10 | import { ARG_SLOTS, BASE_SELECTORS, buildSimulation, newSelectors, PROBE_SHAPES, rpcBody, SIM_REQUEST_BYTES, STRANGER_ETH } from '../simulate' |
| 11 | import { utf8ToBase64 } from '../workflow' |
| 12 | |
| 13 | const sel = (sig: string) => keccak256(stringToBytes(sig)).slice(0, 10) |
| 14 | const 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 | } |
| 32 | const 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. */ |
| 35 | const 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. */ |
| 37 | const 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. */ |
| 40 | const dispatcher = (selectors: string[], extra = '') => `0x${selectors.map((s) => `63${s.slice(2)}811461000057`).join('')}${extra}fe` as Hex |
| 41 | |
| 42 | const PROXY = '0x0000000000000000000000000000000000090000' as Address |
| 43 | const IMPL = '0x00000000000000000000000000000000000b0002' as Address |
| 44 | const OWNER = '0x0000000000000000000000000000000000000001' as Address |
| 45 | const HOLDERS = ['0x00000000000000000000000000000000000000a1', '0x00000000000000000000000000000000000000a2'] as Address[] |
| 46 | const FUNDER = '0x00000000000000000000000000000000000000a3' as Address |
| 47 | const STRANGER = '0x00000000000000000000000000000000000000d1' as Address |
| 48 | const RECEIVER = '0x00000000000000000000000000000000000000d2' as Address |
| 49 | const BAL = 5_000_000_000n |
| 50 | |
| 51 | type Behaviour = 'drain' | 'borrow' | 'flashMint' | 'zeroTransfer' | 'openMint' | 'reorderA' | 'reorderB' | 'tokenGuardC' | 'noLogE' | 'requireTokens' | 'requireEth' | 'allowanceSkip' |
| 52 | type Log = { address: Address; topics: Hex[]; data: Hex } |
| 53 | |
| 54 | /** In-memory token behind the proxy: ERC-20 + Ownable reads, plus the selected malicious functions. */ |
| 55 | class 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 | |
| 234 | const 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 | |
| 259 | const judge = (evm: FakeEvm, over: Partial<UpgradeContext> = {}) => judgeOnProvider(evm.rpc, 'http://fake', 'fake', ctxFor(evm.code, over)) |
| 260 | const verdictOf = (o: ProviderOutcome) => (o.verdict === 'error' ? `error: ${o.error}` : o.verdict === 'pass' ? 'pass' : o.rule) |
| 261 | |
| 262 | describe('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 | |
| 299 | describe('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 | |
| 333 | describe('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 | |
| 408 | describe('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 | |
| 452 | describe('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 |
| 557 | const hex = (...parts: string[]) => `0x${parts.join('')}` as Hex |
| 558 | const IPFS_KEY = '6469706673' // text(4) "ipfs" |
| 559 | const SOLC_KEY = '64736f6c63' // text(4) "solc" |
| 560 | /** A hash full of opcode bytes the walker must not see, with no 0x5b (JUMPDEST) in it. */ |
| 561 | const HASH34 = `1220${'4248f1f4ff'.repeat(6)}4344` |
| 562 | const tailOf = (body: string) => body + (body.length / 2).toString(16).padStart(4, '0') |
| 563 | const ipfsTail = (hash = HASH34) => tailOf(`a2${IPFS_KEY}5822${hash}${SOLC_KEY}43000825`) |
| 564 | const PREFIX = '6080604052348015600e575f5ffd5b5f80fd5bfe' // ends in INVALID |
| 565 | |
| 566 | describe('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 | }) |