diff --git a/packages/bugc/src/evmgen/call-contexts.test.ts b/packages/bugc/src/evmgen/call-contexts.test.ts index da8248f419..8ab682099d 100644 --- a/packages/bugc/src/evmgen/call-contexts.test.ts +++ b/packages/bugc/src/evmgen/call-contexts.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import type * as Format from "@ethdebug/format"; import { Pointer, Program } from "@ethdebug/format"; -import { createMachineState } from "@ethdebug/evm"; import { dereference } from "@ethdebug/pointers"; import { traceLocals } from "#test/evm/locals"; @@ -118,7 +117,7 @@ code { ); it("should give the caller JUMP's arguments their values", async () => { - const { executor, steps, instructionAt } = await traceLocals(source); + const { steps, stateAt, instructionAt } = await traceLocals(source); const index = steps.findIndex((step) => { const instruction = instructionAt(step); @@ -133,9 +132,7 @@ code { // the state after it, at the callee's entry JUMPDEST const { invoke } = instructionAt(steps[index])! .context as Format.Program.Context.Invoke; - const state = createMachineState(executor, { - traceStep: steps[index + 1], - }); + const state = stateAt(index + 1); const cursor = await dereference( (invoke as InternalCall).arguments!.pointer, { state }, diff --git a/packages/bugc/src/evmgen/call-timing.test.ts b/packages/bugc/src/evmgen/call-timing.test.ts index 569bc02100..54e6bf028a 100644 --- a/packages/bugc/src/evmgen/call-timing.test.ts +++ b/packages/bugc/src/evmgen/call-timing.test.ts @@ -9,7 +9,6 @@ * `return` data pointer must read the returned value. */ import { describe, it, expect } from "vitest"; -import { createMachineState } from "@ethdebug/evm"; import { dereference } from "@ethdebug/pointers"; import { Program } from "@ethdebug/format"; import type * as Format from "@ethdebug/format"; @@ -115,7 +114,7 @@ describe("call frame timing", () => { ("voids" in testCase ? testCase.voids : undefined) ?? []; for (const level of [0, 1, 2, 3] as Level[]) { it(`${name} at O${level}`, async () => { - const { program, executor, steps, instructionAt } = await traceLocals( + const { program, steps, stateAt, instructionAt } = await traceLocals( source, level, ); @@ -155,9 +154,7 @@ describe("call frame timing", () => { const ret = (previous!.context as Format.Program.Context.Return) .return; if (ret.data) { - const state = createMachineState(executor, { - traceStep: steps[i], - }); + const state = stateAt(i); const cursor = await dereference(ret.data.pointer, { state }); const view = await cursor.view(state); read.push((await view.read(view.regions[0])).asUint()); diff --git a/packages/bugc/src/evmgen/storage-array-layout.test.ts b/packages/bugc/src/evmgen/storage-array-layout.test.ts index 70f7889dee..ebabab67ad 100644 --- a/packages/bugc/src/evmgen/storage-array-layout.test.ts +++ b/packages/bugc/src/evmgen/storage-array-layout.test.ts @@ -12,11 +12,11 @@ import { describe, it, expect } from "vitest"; import { keccak256 } from "ethereum-cryptography/keccak"; import { bytesToHex } from "ethereum-cryptography/utils"; -import { createMachineState } from "@ethdebug/evm"; import { dereference } from "@ethdebug/pointers"; import type * as Format from "@ethdebug/format"; import { executeProgram } from "#test/evm/behavioral"; +import { createMachineState } from "@ethdebug/evm"; import { traceLocals } from "#test/evm/locals"; const levels = [0, 1, 2, 3] as const; @@ -155,12 +155,11 @@ describe("storage array pointers", () => { for (const [name, { source, pointers }] of Object.entries(cases)) { if (!pointers) continue; it(name, async () => { - const { program, executor, steps } = await traceLocals( + const { program, executor } = await traceLocals( `name Layout;\n${source}`, ); - const state = createMachineState(executor, { - traceStep: steps[steps.length - 1], - }); + // the storage after the transaction + const state = createMachineState(await executor.currentState()); for (const [identifier, regions] of Object.entries(pointers)) { const variable = ( program.context as Format.Program.Context.Variables diff --git a/packages/bugc/src/evmgen/storage-array-push.test.ts b/packages/bugc/src/evmgen/storage-array-push.test.ts index 4b396e8a96..4196905571 100644 --- a/packages/bugc/src/evmgen/storage-array-push.test.ts +++ b/packages/bugc/src/evmgen/storage-array-push.test.ts @@ -7,12 +7,12 @@ import { describe, it, expect } from "vitest"; import { keccak256 } from "ethereum-cryptography/keccak"; import { bytesToHex } from "ethereum-cryptography/utils"; -import { createMachineState } from "@ethdebug/evm"; import { dereference } from "@ethdebug/pointers"; import type * as Format from "@ethdebug/format"; import { compile } from "#compiler"; import { executeProgram } from "#test/evm/behavioral"; +import { createMachineState } from "@ethdebug/evm"; import { traceLocals } from "#test/evm/locals"; const levels = [0, 1, 2, 3] as const; @@ -129,13 +129,12 @@ describe("push on a storage array", () => { } it("gives the pushed elements to the array's pointer", async () => { - const { program, executor, steps } = await traceLocals( + const { program, executor } = await traceLocals( `name Push;\nstorage { [0] h: array; } code { h.push(5); h.push(7); h.push(9); }`, ); - const state = createMachineState(executor, { - traceStep: steps[steps.length - 1], - }); + // the storage after the transaction + const state = createMachineState(await executor.currentState()); const variable = ( program.context as Format.Program.Context.Variables ).variables.find((v) => v.identifier === "h")!; diff --git a/packages/bugc/src/irgen/debug/variables.test.ts b/packages/bugc/src/irgen/debug/variables.test.ts index 35cffa32be..6be4b92597 100644 --- a/packages/bugc/src/irgen/debug/variables.test.ts +++ b/packages/bugc/src/irgen/debug/variables.test.ts @@ -90,9 +90,7 @@ describe("storage string and bytes pointers", () => { it(`read ${length} bytes (level ${level})`, async () => { const program = source(length); const run = await traceLocals(program, level); - const state = createMachineState(run.executor, { - traceStep: run.steps[run.steps.length - 1], - }); + const state = run.stateAt(run.steps.length - 1); const pointerOf = (identifier: string) => ( run.program.context as Format.Program.Context.Variables @@ -190,7 +188,7 @@ code { const run = await executor.execute({ data }); expect(run.success).toBe(true); } - const state = createMachineState(executor); + const state = createMachineState(await executor.currentState()); const pointerOf = (identifier: string) => ( bytecode.runtimeProgram.context as Format.Program.Context.Variables diff --git a/packages/bugc/src/irgen/generate/storage.test.ts b/packages/bugc/src/irgen/generate/storage.test.ts index a5b1c0a1b6..e2fccf7e17 100644 --- a/packages/bugc/src/irgen/generate/storage.test.ts +++ b/packages/bugc/src/irgen/generate/storage.test.ts @@ -129,7 +129,7 @@ describe("packed storage struct field pointers", () => { : undefined; expect(variable?.pointer).toBeDefined(); - const state = createMachineState(executor); + const state = createMachineState(await executor.currentState()); const cursor = await dereference(variable!.pointer!, { state }); const view = await cursor.view(state); const read = async (name: string) => diff --git a/packages/bugc/test/evm/locals.ts b/packages/bugc/test/evm/locals.ts index 751267ae6d..004d6f1ba6 100644 --- a/packages/bugc/test/evm/locals.ts +++ b/packages/bugc/test/evm/locals.ts @@ -6,8 +6,14 @@ */ import { expect } from "vitest"; import { compile } from "#compiler"; -import { Executor, createMachineState, type TraceStep } from "@ethdebug/evm"; -import { Data, dereference, type Machine } from "@ethdebug/pointers"; +import { + Executor, + createMachineState, + createTrace, + type Trace, + type TraceStep, +} from "@ethdebug/evm"; +import { dereference, type Machine } from "@ethdebug/pointers"; import { bytesToHex } from "ethereum-cryptography/utils"; import type * as Format from "@ethdebug/format"; @@ -16,9 +22,10 @@ export type Level = 0 | 1 | 2 | 3; export interface LocalsTrace { program: Format.Program; executor: Executor; - steps: TraceStep[]; - /** The transaction's calldata */ - calldata: Uint8Array; + trace: Trace; + steps: readonly TraceStep[]; + /** The machine state at a step (before its instruction runs) */ + stateAt(index: number): Machine.State; /** The program's instruction at a step's pc */ instructionAt(step: TraceStep): Format.Program.Instruction | undefined; } @@ -45,8 +52,8 @@ export async function traceLocals( : bytecode.runtime, ), ); - const steps: TraceStep[] = []; - await executor.execute({ data: calldata }, (step) => steps.push(step)); + const trace = createTrace(); + await executor.execute({ data: calldata }, trace); const program = bytecode.runtimeProgram; const byOffset = new Map( @@ -55,8 +62,9 @@ export async function traceLocals( return { program, executor, - steps, - calldata: Buffer.from(calldata, "hex"), + trace, + steps: trace.steps, + stateAt: (index) => createMachineState(trace.stateAt(index)), instructionAt: (step) => byOffset.get(step.pc), }; } @@ -214,8 +222,8 @@ export async function check( const local = locals[key!]; const read = local.shape - ? await readReference(pointer, name, local.shape, run, steps[k + 1]) - : await readScalar(pointer, name, run, steps[k + 1]); + ? await readReference(pointer, name, local.shape, run, k + 1) + : await readScalar(pointer, name, run, k + 1); // Never an earlier value than the last one read, nor than the // latest one whose statement has finished (that would be stale); @@ -293,25 +301,11 @@ function stringify(value: unknown): string { * Dereference a pointer at a step and read its regions in order, * checking each against the step's memory or the calldata. */ -async function regionsAt( - pointer: unknown, - { executor, calldata }: LocalsTrace, - step: TraceStep, -) { - const state: Machine.State = { - ...createMachineState(executor, { traceStep: step }), - calldata: { - length: Promise.resolve(BigInt(calldata.length)), - read: async ({ slice: { offset, length } }) => { - const data = new Uint8Array(Number(length)); - data.set(calldata.slice(Number(offset), Number(offset + length))); - return Data.fromBytes(data); - }, - }, - }; +async function regionsAt(pointer: unknown, run: LocalsTrace, index: number) { + const state = run.stateAt(index); + const { memory, calldata } = run.trace.stateAt(index); const cursor = await dereference(pointer as Format.Pointer, { state }); const view = await cursor.view(state); - const memory = step.memory ?? new Uint8Array(); const regions = [...view.regions]; let next = 0; return { @@ -341,7 +335,7 @@ async function regionsAt( } const actual = new Uint8Array(Number(length)); actual.set(bytes.slice(Number(start), Number(start + length))); - if (data.toHex() !== "0x" + Buffer.from(actual).toString("hex")) { + if (data.toHex() !== "0x" + bytesToHex(actual)) { throw new Error( `${name} does not read ${region.location} at ${start}`, ); @@ -357,9 +351,9 @@ async function readScalar( pointer: unknown, name: string, run: LocalsTrace, - step: TraceStep, + index: number, ): Promise { - const regions = await regionsAt(pointer, run, step); + const regions = await regionsAt(pointer, run, index); regions.seek(name); return (await regions.read(name)).toHex(); } @@ -377,9 +371,9 @@ async function readReference( name: string, shape: Shape, run: LocalsTrace, - step: TraceStep, + index: number, ): Promise { - const regions = await regionsAt(pointer, run, step); + const regions = await regionsAt(pointer, run, index); regions.seek(name); if (shape.kind === "calldata") { diff --git a/packages/bugc/test/examples/runners.ts b/packages/bugc/test/examples/runners.ts index 91211b4ff4..547b99347d 100644 --- a/packages/bugc/test/examples/runners.ts +++ b/packages/bugc/test/examples/runners.ts @@ -162,7 +162,7 @@ async function checkScalarValue( } // Dereference the pointer - const state = createMachineState(executor); + const state = createMachineState(await executor.currentState()); const cursor = await dereference(pointer, { state }); const view = await cursor.view(state); @@ -234,7 +234,7 @@ async function checkRegionValues( } // Dereference the pointer - const state = createMachineState(executor); + const state = createMachineState(await executor.currentState()); const cursor = await dereference(pointer, { state }); const view = await cursor.view(state); diff --git a/packages/evm/CHANGELOG.md b/packages/evm/CHANGELOG.md index 554993fcbf..9ed121056f 100644 --- a/packages/evm/CHANGELOG.md +++ b/packages/evm/CHANGELOG.md @@ -7,6 +7,58 @@ specification itself are tracked in the root ## Unreleased +### Added + +- Transactions from any account: `deploy({ from, create, value })` + returns the new contract's address, `call({ from, to, input, value })` + calls any address, and `fund(address, balance)` sets an account's + balance. `getStorage`, `setStorage` and `getCode` take an optional + address ([#375]). +- A `block` option for each call (number, timestamp, prevrandao, + coinbase, base fee, blob base fee, gas limit), and a `chainId` + executor option, so contracts read these values. `BLOCKHASH` of one of + the 256 previous blocks is keccak256 of its number ([#375]). +- `createTrace({ memory })` records one transaction: pass it to `call`, + `deploy` or `execute`. `trace.stateAt(index)` gives the complete state + at a step (stack, memory, storage, transient storage, calldata, return + data, code), with storage written in frames that later reverted undone + from the revert on. `trace.frames` lists the message frames. The + `memory` option records a copy at every step (`"full"`, the default), + a new copy only when memory changed (`"changed"`), or none (`"none"`, + and then `stateAt` gives no memory) ([#375]). +- Message frame events: a `frame` handler gets an `enter` and an `exit` + event for each call or create, with its depth, addresses, caller, + calldata, value, return data, whether it reverted, and its first and + last step indexes ([#375]). +- A trace's step state carries its position (`at`), so + `createMachineState(trace.stateAt(i))` has the step's pc, opcode and + index ([#375]). +- `executor.currentState()` gives the executor's state now, as a step + state ([#375]). +- `endTransaction()` clears transient storage, the warm address and + slot sets and the slots' original values. `deploy(options)` and `call` + end their transaction; `execute` does not ([#375]). + +### Changed + +- **Breaking:** a `TraceStep` is the event only: `pc`, `opcode`, `op` + (the opcode byte), `gasRemaining`, `gasCost`, `depth`, `address` and + `codeAddress`. Its stack and memory moved to `Trace.stateAt` ([#375]). +- **Breaking:** `createMachineState(state, { pc, opcode, traceIndex })` + takes a complete step state (from `Trace.stateAt` or + `Executor.currentState`) instead of an executor, and never reads the + executor's current state for a past step ([#375]). +- **Breaking:** `createTraceCollector` is removed; use `createTrace` + ([#375]). +- A step handler gets the step's index as its second argument ([#375]). +- The package no longer uses Node's `Buffer`, so it runs in browsers + without a polyfill ([#375]). + +### Fixed + +- A run that throws (as BASEFEE does without a block) no longer leaves + the executor's state checkpoints open ([#375]). + ## 0.1.0-preview.2 — 2026-10-07 Updated `@ethdebug/pointers` to `0.1.0-preview.2`. @@ -40,3 +92,4 @@ First publication. [#300]: https://github.com/ethdebug/format/pull/300 [#314]: https://github.com/ethdebug/format/pull/314 +[#375]: https://github.com/ethdebug/format/pull/375 diff --git a/packages/evm/README.md b/packages/evm/README.md index dbbd94e245..f176873503 100644 --- a/packages/evm/README.md +++ b/packages/evm/README.md @@ -10,3 +10,21 @@ the [documentation](https://ethdebug.github.io/format/). This package uses `package.json` `imports` for internal modules. Consumers must use `moduleResolution` `node16`, `nodenext`, or `bundler`. The legacy `node10` resolution is not supported. + +## Usage + +```typescript +import { Executor, createTrace, createMachineState } from "@ethdebug/evm"; + +const executor = new Executor({ chainId: 1n }); +await executor.fund(alice, 10n ** 18n); +const { address } = await executor.deploy({ from: alice, create }); + +// Record one transaction, then read the state at any of its steps +const trace = createTrace({ memory: "changed" }); +await executor.call( + { from: alice, to: address!, input, block: { number: 1n } }, + trace, +); +const state = createMachineState(trace.stateAt(i)); +``` diff --git a/packages/evm/src/executor.test.ts b/packages/evm/src/executor.test.ts index 263f8fcb40..0d5551b162 100644 --- a/packages/evm/src/executor.test.ts +++ b/packages/evm/src/executor.test.ts @@ -1,6 +1,19 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { bytesToHex } from "ethereum-cryptography/utils"; +import { bytesToHex, hexToBytes } from "ethereum-cryptography/utils"; +import { keccak256 } from "ethereum-cryptography/keccak"; import { Executor } from "#executor"; +import type { BlockOptions } from "#executor"; +import type { FrameEvent, TraceStep } from "#trace"; +import { asm, creation, word } from "../test/bytecode.js"; +import { + alice, + bob, + carol, + deployed, + forward, + funded, + store, +} from "../test/contracts.js"; // Simple bytecodes for testing: // @@ -160,3 +173,275 @@ describe("Executor", () => { }); }); }); + +describe("Executor transactions", () => { + let executor: Executor; + + beforeEach(async () => { + executor = await funded(); + }); + + describe("deploy and call", () => { + it("deploys contracts from any account", async () => { + const first = await executor.deploy({ + from: alice, + create: creation(store), + }); + const second = await executor.deploy({ + from: bob, + create: creation(forward(first.address!)), + }); + expect(first.success && second.success).toBe(true); + expect(second.address).not.toBe(first.address); + expect(bytesToHex(await executor.getCode(first.address))).toBe(store); + }); + + it("calls between contracts, reporting frames", async () => { + const inner = await deployed(executor, store); + const outer = await deployed(executor, forward(inner)); + + const events: FrameEvent[] = []; + const steps: TraceStep[] = []; + const result = await executor.call( + { from: carol, to: outer, input: word(7n) }, + { step: (step) => steps.push(step), frame: (e) => events.push(e) }, + ); + + expect(result.success).toBe(true); + expect(bytesToHex(result.returnValue)).toBe(word(7n)); + expect(await executor.getStorage(0n, inner)).toBe(7n); + expect(await executor.getStorage(0n, outer)).toBe(7n); + + expect(events.map(({ kind, frame }) => [kind, frame.depth])).toEqual([ + ["enter", 0], + ["enter", 1], + ["exit", 1], + ["exit", 0], + ]); + const [enterOuter, enterInner] = events.map(({ frame }) => frame); + expect(enterOuter).toMatchObject({ address: outer, caller: carol }); + expect(enterInner).toMatchObject({ address: inner, caller: outer }); + expect(bytesToHex(enterInner.calldata)).toBe(word(7n)); + const exit = events[2]; + expect(exit.kind === "exit" && bytesToHex(exit.returnData)).toBe( + word(7n), + ); + for (const step of steps) { + expect(step.address).toBe(step.depth === 0 ? outer : inner); + } + const inside = steps.map(({ depth }) => depth === 1); + const [, enterEvent, exitInner] = events; + expect(enterEvent.kind === "enter" && enterEvent.first).toBe( + inside.indexOf(true), + ); + expect(exitInner.kind === "exit" && exitInner.last).toBe( + inside.lastIndexOf(true), + ); + expect(steps[0].op).toBe(0x60); + }); + + it("keeps the legacy deploy and execute", async () => { + await executor.deploy(constructorCode); + const steps: TraceStep[] = []; + await executor.execute({}, (step) => steps.push(step)); + expect(await executor.getStorage(0n)).toBe(42n); + expect(steps.map(({ opcode }) => opcode)).toEqual([ + "PUSH1", + "PUSH1", + "SSTORE", + "STOP", + ]); + }); + + it("sends value from funded accounts only", async () => { + const result = await executor.deploy({ + from: alice, + create: creation("00"), + value: 5n, + }); + expect(result.address).toBeDefined(); + + const poor = "0x" + "dd".repeat(20); + expect( + (await executor.call({ from: poor, to: alice, value: 1n })).success, + ).toBe(false); + }); + }); + + describe("gas", () => { + it("gives each step's exact cost", async () => { + const address = await deployed(executor, store); + const steps: TraceStep[] = []; + await executor.call({ from: alice, to: address, input: word(1n) }, (s) => + steps.push(s), + ); + expect(steps.map(({ opcode, gasCost }) => [opcode, gasCost])).toEqual([ + ["PUSH1", 3n], + ["CALLDATALOAD", 3n], + ["DUP1", 3n], + ["PUSH1", 3n], + ["SSTORE", 22100n], // cold, zero to nonzero + ["PUSH1", 3n], + ["MSTORE", 6n], // with one word of memory expansion + ["PUSH1", 3n], + ["PUSH1", 3n], + ["RETURN", 0n], + ]); + for (let i = 0; i + 1 < steps.length; i++) { + expect(steps[i].gasRemaining - steps[i + 1].gasRemaining).toBe( + steps[i].gasCost, + ); + } + }); + }); + + describe("block", () => { + // returns PREVRANDAO, NUMBER, TIMESTAMP, CHAINID, COINBASE, BASEFEE, + // BLOBBASEFEE and the previous block's BLOCKHASH + const blockCode = asm(` + PREVRANDAO PUSH1 0 MSTORE NUMBER PUSH1 32 MSTORE + TIMESTAMP PUSH1 64 MSTORE CHAINID PUSH1 96 MSTORE + COINBASE PUSH1 128 MSTORE BASEFEE PUSH1 160 MSTORE + BLOBBASEFEE PUSH1 192 MSTORE + PUSH1 1 NUMBER SUB BLOCKHASH PUSH1 224 MSTORE + PUSH2 256 PUSH1 0 RETURN + `); + + it("lets contracts read the block", async () => { + executor = await funded(new Executor({ chainId: 31337n })); + const address = await deployed(executor, blockCode); + const block: BlockOptions = { + number: 12n, + timestamp: 1_700_000_000n, + prevrandao: 0xabcdefn, + coinbase: "0x" + "c0".repeat(20), + baseFee: 7n, + }; + const result = await executor.call({ from: alice, to: address, block }); + expect(bytesToHex(result.returnValue)).toBe( + [0xabcdefn, 12n, 1_700_000_000n, 31337n, BigInt(block.coinbase!), 7n] + .map((value) => word(value)) + .join("") + + word(1n) + + bytesToHex(keccak256(hexToBytes(word(11n)))), + ); + }); + + it("runs in a zero block without the option", async () => { + await executor.deploy( + creation( + asm(`PREVRANDAO NUMBER TIMESTAMP PUSH1 0 MSTORE + PUSH1 32 MSTORE PUSH1 64 MSTORE PUSH1 96 PUSH1 0 RETURN`), + ), + ); + const result = await executor.execute(); + expect(bytesToHex(result.returnValue)).toBe(word(0n).repeat(3)); + }); + }); + + describe("transaction boundaries", () => { + const sstoreCost = async ( + run: (trace: (step: TraceStep) => void) => Promise, + ) => { + let cost: bigint | undefined; + await run((step) => { + if (step.opcode === "SSTORE") cost = step.gasCost; + }); + return cost!; + }; + + it("clears transient storage between transactions", async () => { + // stores TLOAD(1) at slot 0, then TSTORE(1, 42) + const address = await deployed( + executor, + asm(`PUSH1 1 TLOAD PUSH1 0 SSTORE PUSH1 42 PUSH1 1 TSTORE`), + ); + await executor.call({ from: alice, to: address }); + await executor.call({ from: bob, to: address }); + expect(await executor.getStorage(0n, address)).toBe(0n); + }); + + it("makes slots cold again after a transaction", async () => { + const address = await deployed(executor, store); + const costs: bigint[] = []; + for (const from of [alice, bob]) { + costs.push( + await sstoreCost((step) => + executor.call({ from, to: address, input: word(1n) }, step), + ), + ); + } + // same value again: a warm slot would cost 100 + expect(costs).toEqual([22100n, 2200n]); + }); + + it("resets slots' original values after a transaction", async () => { + const address = await deployed(executor, store); + await executor.call({ from: alice, to: address, input: word(1n) }); + const cost = await sstoreCost((step) => + executor.call({ from: alice, to: address, input: word(2n) }, step), + ); + // cold (2100) + changing a clean nonzero slot (2900) + expect(cost).toBe(5000n); + }); + + it("ends legacy executes only on request", async () => { + await executor.deploy(creation(store)); + const run = () => + sstoreCost((step) => executor.execute({ data: word(1n) }, step)); + expect(await run()).toBe(22100n); + expect(await run()).toBe(100n); // still warm + await executor.endTransaction(); + expect(await run()).toBe(2200n); + }); + }); + + describe("errors", () => { + it("rejects with a frame handler's error", async () => { + const inner = await deployed(executor, store); + const outer = await deployed(executor, forward(inner)); + const call = (fail: FrameEvent["kind"]) => + executor.call( + { from: alice, to: outer, input: word(3n) }, + { + frame: (event) => { + if (event.kind === fail && event.frame.depth === 1) { + throw new Error(`failed on ${fail}`); + } + }, + }, + ); + await expect(call("enter")).rejects.toThrow("failed on enter"); + await expect(call("exit")).rejects.toThrow("failed on exit"); + + // the executor still works + const result = await executor.call({ + from: alice, + to: outer, + input: word(4n), + }); + expect(result.success).toBe(true); + expect(await executor.getStorage(0n, inner)).toBe(4n); + }); + + it("rejects with a step handler's error", async () => { + const address = await deployed(executor, store); + await expect( + executor.call({ from: alice, to: address }, () => { + throw new Error("failed on step"); + }), + ).rejects.toThrow("failed on step"); + }); + + it("undoes a run that throws", async () => { + // SSTORE, then BASEFEE, which throws in the zero block + await executor.deploy(creation(asm(`PUSH1 9 PUSH1 0 SSTORE BASEFEE`))); + await expect(executor.execute()).rejects.toThrow(); + expect(await executor.getStorage(0n)).toBe(0n); + + const result = await executor.execute({ block: { baseFee: 1n } }); + expect(result.success).toBe(true); + expect(await executor.getStorage(0n)).toBe(9n); + }); + }); +}); diff --git a/packages/evm/src/executor.ts b/packages/evm/src/executor.ts index 810eb4b87c..03b74e51ae 100644 --- a/packages/evm/src/executor.ts +++ b/packages/evm/src/executor.ts @@ -6,13 +6,35 @@ */ import { EVM } from "@ethereumjs/evm"; -import type { InterpreterStep } from "@ethereumjs/evm"; +import type { InterpreterStep, Message } from "@ethereumjs/evm"; import { SimpleStateManager } from "@ethereumjs/statemanager"; -import { Common, Hardfork, Mainnet } from "@ethereumjs/common"; -import { Address, Account } from "@ethereumjs/util"; +import { + Common, + Hardfork, + Mainnet, + createCustomCommon, +} from "@ethereumjs/common"; +import { + Address, + Account, + generateAddress, + generateAddress2, + bigIntToBytes, +} from "@ethereumjs/util"; +import { keccak256 } from "ethereum-cryptography/keccak"; import { hexToBytes, bytesToHex } from "ethereum-cryptography/utils"; -import type { TraceStep, TraceHandler } from "#trace"; +import { recorder } from "#trace"; +import type { + TraceStep, + TraceHandler, + TraceOptions, + MessageFrame, + FrameEvent, + Trace, + Recorder, + StepState, +} from "#trace"; /** * Hardfork used for execution. Current solc targets prague by default, @@ -20,6 +42,37 @@ import type { TraceStep, TraceHandler } from "#trace"; */ const hardfork = Hardfork.Prague; +const defaultGasLimit = 10_000_000n; + +/** + * Options for creating an executor. + */ +export interface ExecutorOptions { + /** Chain id (what CHAINID returns); default 1 */ + chainId?: bigint; +} + +/** + * The block a call runs in. Fields left out are zero, except + * `gasLimit` (30,000,000) and `blobBaseFee` (1). BLOCKHASH of block + * `n` (one of the 256 blocks before `number`) is keccak256 of `n` as a + * 32-byte word. + */ +export interface BlockOptions { + number?: bigint; + timestamp?: bigint; + /** PREVRANDAO, as a number or 32-byte hex */ + prevrandao?: bigint | string; + /** COINBASE, as hex */ + coinbase?: string; + /** BASEFEE */ + baseFee?: bigint; + /** GASLIMIT */ + gasLimit?: bigint; + /** BLOBBASEFEE */ + blobBaseFee?: bigint; +} + /** * Options for executing a contract call. */ @@ -34,6 +87,39 @@ export interface ExecutionOptions { caller?: Address; /** Gas limit for execution */ gasLimit?: bigint; + /** + * Block to run in. Without it, the block's fields are all zero, and + * BASEFEE and BLOBBASEFEE fail (the block has neither) + */ + block?: BlockOptions; +} + +/** + * Options for a transaction that creates a contract. + */ +export interface DeployOptions { + /** Sender (and origin), as hex */ + from: string; + /** Creation bytecode (with constructor arguments), as hex */ + create: string; + value?: bigint; + gasLimit?: bigint; + block?: BlockOptions; +} + +/** + * Options for a transaction that calls an address. + */ +export interface CallOptions { + /** Sender (and origin), as hex */ + from: string; + /** Called address, as hex */ + to: string; + /** Calldata, as hex */ + input?: string; + value?: bigint; + gasLimit?: bigint; + block?: BlockOptions; } /** @@ -52,6 +138,14 @@ export interface ExecutionResult { error?: unknown; } +/** + * Result of a transaction that creates a contract. + */ +export interface DeployResult extends ExecutionResult { + /** The new contract's address (absent when creation failed) */ + address?: string; +} + interface ExecResult { exceptionError?: unknown; executionGasUsed?: bigint; @@ -61,33 +155,37 @@ interface ExecResult { interface ResultWithExec extends ExecResult { execResult?: ExecResult; + createdAddress?: Address; } +type RunCallOpts = Parameters[0]; + /** * EVM executor for running bytecode in an isolated environment. * * Wraps @ethereumjs/evm to provide a simple interface for: - * - Deploying contracts - * - Executing contract calls + * - Funding accounts + * - Deploying contracts and calling them, as transactions from any + * account, in a given block * - Reading/writing storage - * - Capturing execution traces + * - Capturing execution traces and message frames + * + * The "current contract" is the one deployed last; `execute`, + * `getStorage`, `setStorage` and `getCode` use it by default. */ export class Executor { private evm: EVM; private stateManager: SimpleStateManager; private contractAddress: Address; private deployerAddress: Address; + private options: ExecutorOptions; + /** Changes whenever storage may have changed */ + private version = 0; - constructor() { - const common = new Common({ - chain: Mainnet, - hardfork, - }); + constructor(options: ExecutorOptions = {}) { + this.options = options; this.stateManager = new SimpleStateManager(); - this.evm = new EVM({ - common, - stateManager: this.stateManager, - }); + this.evm = this.createEvm(); // Use a fixed contract address for testing this.contractAddress = new Address( @@ -100,6 +198,21 @@ export class Executor { ); } + private createEvm(): EVM { + const { chainId } = this.options; + const common = + chainId === undefined + ? new Common({ chain: Mainnet, hardfork }) + : createCustomCommon({ chainId: Number(chainId) }, Mainnet, { + hardfork, + }); + return new EVM({ + common, + stateManager: this.stateManager, + blockchain: blockchain(), + }); + } + /** * Get the deployer address used for deployment. */ @@ -115,11 +228,61 @@ export class Executor { } /** - * Deploy bytecode and set the contract address. + * Set an account's balance (keeping its nonce). + */ + async fund(address: string, balance: bigint): Promise { + const at = toAddress(address); + const account = (await this.stateManager.getAccount(at)) ?? new Account(); + account.balance = balance; + await this.stateManager.putAccount(at, account); + } + + /** + * Deploy bytecode from the fixed deployer and make it the current + * contract. Throws when creation fails. * * @param bytecode - Contract creation bytecode as hex string */ - async deploy(bytecode: string): Promise { + async deploy(bytecode: string): Promise; + /** + * Send a transaction that creates a contract, and make the contract + * the current contract. Ends the transaction (see `endTransaction`). + */ + async deploy( + options: DeployOptions, + trace?: TraceOptions | TraceHandler | Trace, + ): Promise; + async deploy( + options: string | DeployOptions, + trace?: TraceOptions | TraceHandler | Trace, + ): Promise { + if (typeof options === "string") { + return this.deployFromDeployer(options); + } + + const from = toAddress(options.from); + const raw = await this.transaction( + { + caller: from, + origin: from, + to: undefined, + data: hexToBytes(options.create), + value: options.value ?? 0n, + gasLimit: options.gasLimit ?? defaultGasLimit, + block: toBlock(options.block), + }, + trace, + ); + + const result = toExecutionResult(raw); + if (!result.success || !raw.createdAddress) { + return result; + } + this.contractAddress = raw.createdAddress; + return { ...result, address: raw.createdAddress.toString() }; + } + + private async deployFromDeployer(bytecode: string): Promise { const code = hexToBytes(bytecode); // Initialize deployer account with 1 ETH @@ -131,12 +294,12 @@ export class Executor { await this.stateManager.putAccount(this.contractAddress, contractAccount); // Use runCall with undefined 'to' to simulate CREATE - const result = await this.evm.runCall({ + const result = await this.run({ caller: this.deployerAddress, origin: this.deployerAddress, to: undefined, data: code, - gasLimit: 10_000_000n, + gasLimit: defaultGasLimit, value: 0n, }); @@ -154,55 +317,308 @@ export class Executor { } /** - * Execute a call to the deployed contract. + * Send a transaction that calls an address. Ends the transaction + * (see `endTransaction`). + */ + async call( + options: CallOptions, + trace?: TraceOptions | TraceHandler | Trace, + ): Promise { + const from = toAddress(options.from); + const raw = await this.transaction( + { + caller: from, + origin: from, + to: toAddress(options.to), + data: options.input ? hexToBytes(options.input) : new Uint8Array(), + value: options.value ?? 0n, + gasLimit: options.gasLimit ?? defaultGasLimit, + block: toBlock(options.block), + }, + trace, + ); + return toExecutionResult(raw); + } + + private async transaction( + opts: RunCallOpts, + trace?: TraceOptions | TraceHandler | Trace, + ): Promise { + try { + return await this.run(opts, trace); + } finally { + await this.endTransaction(); + } + } + + /** + * End the transaction: clear transient storage, the warm address + * and slot sets and the original storage values (EIP-2200), and + * remove touched empty accounts (EIP-161), as at a transaction + * boundary. `deploy(options)` and `call` do this; `execute` does + * not. + * + * Gas still differs from a chain's: no intrinsic gas is charged, + * and the sender, recipient, precompiles and COINBASE are not warm + * at the start. + */ + async endTransaction(): Promise { + this.evm.transientStorage.clear(); + this.stateManager.originalStorageCache.clear(); + await this.evm.journal.cleanup(); + } + + /** + * The executor's state now, as a step state: storage and code of + * the given contract (default: the current contract), read live, + * with an empty stack, memory, calldata and return data. Use this + * only to read the present; a trace gives the state at its steps. + */ + async currentState(address?: string): Promise { + const at = address ?? this.contractAddress.toString(); + return { + stack: [], + memory: new Uint8Array(), + storage: (slot) => this.getStorage(slot, at), + transient: async (slot) => + toBigInt(this.evm.transientStorage.get(toAddress(at), toWord(slot))), + calldata: new Uint8Array(), + returndata: new Uint8Array(), + code: await this.getCode(at), + }; + } + + /** + * Execute a call to the current contract. * * @param options - Execution options (value, data, gas, etc.) - * @param traceHandler - Optional handler for execution trace steps + * @param trace - Optional handler for execution trace steps, trace + * handlers, or a trace to record (see `createTrace`) */ async execute( options: ExecutionOptions = {}, - traceHandler?: TraceHandler, + trace?: TraceOptions | TraceHandler | Trace, ): Promise { - const runCallOpts = { - to: this.contractAddress, - caller: options.caller ?? this.deployerAddress, - origin: options.origin ?? this.deployerAddress, - data: options.data ? hexToBytes(options.data) : new Uint8Array(), - value: options.value ?? 0n, - gasLimit: options.gasLimit ?? 10_000_000n, - }; + const raw = await this.run( + { + to: this.contractAddress, + caller: options.caller ?? this.deployerAddress, + origin: options.origin ?? this.deployerAddress, + data: options.data ? hexToBytes(options.data) : new Uint8Array(), + value: options.value ?? 0n, + gasLimit: options.gasLimit ?? defaultGasLimit, + block: toBlock(options.block), + }, + trace, + ); + return toExecutionResult(raw); + } - let listener: ((step: InterpreterStep) => void) | undefined; - if (traceHandler) { - listener = (step: InterpreterStep) => { - const traceStep: TraceStep = { - pc: step.pc, - opcode: step.opcode.name, - stack: [...step.stack], - memory: new Uint8Array(step.memory), - gasRemaining: step.gasLeft, - }; - traceHandler(traceStep); - }; - this.evm.events.on("step", listener); + /** + * Run one message from outside, reporting its steps and frames. + * + * Handler errors do not stop the EVM mid-instruction: the first one + * is kept, no handler runs after it, and the run rejects with it. + */ + private async run( + opts: RunCallOpts, + trace?: TraceOptions | TraceHandler | Trace, + ): Promise { + this.version++; + const record: Recorder | undefined = + trace && recorder in trace ? trace[recorder] : undefined; + if (record?.started) { + throw new Error("a trace records one transaction"); + } + const options: TraceOptions = + typeof trace === "function" + ? { step: trace } + : record + ? {} + : ((trace as TraceOptions | undefined) ?? {}); + + if (!record && !options.step && !options.frame) { + return this.runCall(opts); } - const result = await this.evm.runCall(runCallOpts); + const { events } = this.evm; + const stateManager = this.stateManager; + const frames: MessageFrame[] = []; + let index = 0; + let failure: { error: unknown } | undefined; + const guard = (action: () => void) => { + if (failure) return; + try { + action(); + } catch (error) { + failure = { error }; + } + }; - if (listener) { - this.evm.events.removeListener("step", listener); - } + const enter = async (message: Message): Promise => { + const create = message.to === undefined; + // ethereumjs moves the init code from `data` to `code` only + // after this event + const initcode = create + ? message.code instanceof Uint8Array + ? message.code + : message.data + : undefined; + const address = create + ? await createdAddress(stateManager, message, initcode!) + : message.to!; + const codeAddress = create ? address : message.codeAddress; + const frame: MessageFrame = { + depth: message.depth, + address: address.toString(), + codeAddress: codeAddress.toString(), + caller: message.caller.toString(), + calldata: create ? new Uint8Array() : message.data, + ...(create ? { initcode } : {}), + value: message.value, + create, + delegatecall: message.delegatecall, + static: message.isStatic, + }; + frames.push(frame); + const code = create + ? initcode! + : record + ? await stateManager.getCode(codeAddress) + : undefined; + guard(() => { + if (record) record.enter(frame, code!); + else options.frame?.({ kind: "enter", frame, first: index }); + }); + }; - const rawResult = result as ResultWithExec; - const execResult = (rawResult.execResult || rawResult) as ExecResult; + const beforeMessage = (message: Message, resolve?: () => void) => { + enter(message) + .catch((error) => { + failure ??= { error }; + }) + .finally(() => resolve?.()); + }; - return { - success: execResult.exceptionError === undefined, - gasUsed: execResult.executionGasUsed || 0n, - returnValue: execResult.returnValue || new Uint8Array(), - logs: execResult.logs || [], - error: execResult.exceptionError, + const afterMessage = (result: ResultWithExec) => { + const frame = frames.pop(); + if (!frame) return; + const exec = result.execResult ?? result; + const error = exec.exceptionError as { error?: string } | undefined; + const event: FrameEvent & { kind: "exit" } = { + kind: "exit", + frame, + last: index - 1, + returnData: exec.returnValue ?? new Uint8Array(), + reverted: error !== undefined, + ...(error ? { error: String(error.error ?? error) } : {}), + }; + guard(() => { + if (record) record.exit(event); + else options.frame?.(event); + }); }; + + const report = (interpreterStep: InterpreterStep) => { + const { depth, opcode } = interpreterStep; + const frame = frames[frames.length - 1]; + const traceStep: TraceStep = { + pc: interpreterStep.pc, + opcode: opcode.name, + op: opcode.code, + // ethereumjs gives the step's whole cost as `dynamicFee` + gasCost: opcode.dynamicFee ?? BigInt(opcode.fee), + gasRemaining: interpreterStep.gasLeft, + depth, + address: frame.address, + codeAddress: frame.codeAddress, + }; + guard(() => { + if (record) { + record.step(traceStep, interpreterStep.stack, interpreterStep.memory); + } else { + options.step?.(traceStep, index); + } + }); + index++; + }; + + // A recorded trace needs a slot's value just before SLOAD and + // SSTORE, which takes an asynchronous read + const step = record + ? (interpreterStep: InterpreterStep, resolve?: () => void) => { + const { opcode, stack, address } = interpreterStep; + if (failure || (opcode.code !== 0x54 && opcode.code !== 0x55)) { + report(interpreterStep); + resolve?.(); + return; + } + const slot = stack[stack.length - 1]; + stateManager + .getStorage(address, toWord(slot)) + .then((value) => { + guard(() => + record.observe(address.toString(), slot, toBigInt(value)), + ); + report(interpreterStep); + }) + .catch((error) => { + failure ??= { error }; + }) + .finally(() => resolve?.()); + } + : report; + + events.on("beforeMessage", beforeMessage); + events.on("afterMessage", afterMessage); + if (record || options.step) { + events.on("step", step); + } + let result: ResultWithExec; + try { + result = await this.runCall(opts); + } finally { + events.removeListener("beforeMessage", beforeMessage); + events.removeListener("afterMessage", afterMessage); + events.removeListener("step", step); + } + if (failure) { + throw failure.error; + } + if (record) { + const version = this.version; + record.end(async (address, slot) => { + if (this.version !== version) { + throw new Error( + `slot ${slot} of ${address} was not touched by the trace's ` + + "transaction, and the executor has changed since", + ); + } + return this.getStorage(slot, address); + }); + } + return result; + } + + /** + * `runCall`, undoing the open checkpoints if it throws (as, without + * a block, BASEFEE does) + */ + private async runCall(opts: RunCallOpts): Promise { + this.version++; + try { + return (await this.evm.runCall(opts)) as ResultWithExec; + } catch (error) { + const journal = this.evm.journal as unknown as { + journalHeight: number; + revert(): Promise; + }; + while (journal.journalHeight > 0) { + await journal.revert(); + this.evm.transientStorage.revert(); + } + throw error; + } } /** @@ -227,45 +643,33 @@ export class Executor { const runCodeOpts = { code, data: options.data ? hexToBytes(options.data) : new Uint8Array(), - gasLimit: options.gasLimit ?? 10_000_000n, + gasLimit: options.gasLimit ?? defaultGasLimit, value: options.value ?? 0n, origin: options.origin ?? new Address(hexToBytes("00".repeat(20))), caller: options.caller ?? new Address(hexToBytes("00".repeat(20))), address: tempAddress, + block: toBlock(options.block), }; + this.version++; const result = await this.evm.runCode(runCodeOpts); - - const rawResult = result as ResultWithExec; - const execResult = (rawResult.execResult || rawResult) as ExecResult; - - return { - success: execResult.exceptionError === undefined, - gasUsed: execResult.executionGasUsed || 0n, - returnValue: execResult.returnValue || new Uint8Array(), - logs: execResult.logs || [], - error: execResult.exceptionError, - }; + return toExecutionResult(result as ResultWithExec); } /** * Get storage value at a specific slot. * * @param slot - Storage slot as bigint + * @param address - Contract address as hex (default: the current + * contract) * @returns Storage value as bigint */ - async getStorage(slot: bigint): Promise { - const slotBuffer = Buffer.alloc(32); - const hex = slot.toString(16).padStart(64, "0"); - slotBuffer.write(hex, "hex"); - + async getStorage(slot: bigint, address?: string): Promise { const value = await this.stateManager.getStorage( - this.contractAddress, - slotBuffer, + address ? toAddress(address) : this.contractAddress, + toWord(slot), ); - - if (value.length === 0) return 0n; - return BigInt("0x" + bytesToHex(value)); + return toBigInt(value); } /** @@ -273,41 +677,118 @@ export class Executor { * * @param slot - Storage slot as bigint * @param value - Value to store as bigint + * @param address - Contract address as hex (default: the current + * contract) */ - async setStorage(slot: bigint, value: bigint): Promise { - const slotBuffer = Buffer.alloc(32); - const slotHex = slot.toString(16).padStart(64, "0"); - slotBuffer.write(slotHex, "hex"); - - const valueBuffer = Buffer.alloc(32); - const hex = value.toString(16).padStart(64, "0"); - valueBuffer.write(hex, "hex"); - + async setStorage( + slot: bigint, + value: bigint, + address?: string, + ): Promise { + this.version++; await this.stateManager.putStorage( - this.contractAddress, - slotBuffer, - valueBuffer, + address ? toAddress(address) : this.contractAddress, + toWord(slot), + toWord(value), ); } /** - * Get the deployed bytecode at the contract address. + * Get the deployed bytecode at an address (default: the current + * contract). */ - async getCode(): Promise { - return this.stateManager.getCode(this.contractAddress); + async getCode(address?: string): Promise { + return this.stateManager.getCode( + address ? toAddress(address) : this.contractAddress, + ); } /** * Reset the EVM state to a fresh instance. */ async reset(): Promise { + this.version++; this.stateManager = new SimpleStateManager(); - this.evm = new EVM({ - common: new Common({ - chain: Mainnet, - hardfork, - }), - stateManager: this.stateManager, - }); + this.evm = this.createEvm(); } } + +/** + * The address a create message deploys to + */ +async function createdAddress( + stateManager: SimpleStateManager, + message: Message, + initcode: Uint8Array, +): Promise
{ + if (message.salt) { + return new Address( + generateAddress2(message.caller.bytes, message.salt, initcode), + ); + } + // the sender's nonce is already incremented + const account = await stateManager.getAccount(message.caller); + const nonce = (account?.nonce ?? 1n) - 1n; + return new Address( + generateAddress(message.caller.bytes, bigIntToBytes(nonce)), + ); +} + +/** + * Block hashes for BLOCKHASH: keccak256 of the block number as a word + */ +function blockchain() { + const chain = { + async getBlock(number: number) { + return { hash: () => keccak256(toWord(BigInt(number))) }; + }, + async putBlock() {}, + shallowCopy: () => chain, + }; + return chain; +} + +function toAddress(hex: string): Address { + return new Address(hexToBytes(hex)); +} + +function toWord(value: bigint): Uint8Array { + return hexToBytes(value.toString(16).padStart(64, "0")); +} + +function toBigInt(bytes: Uint8Array): bigint { + return bytes.length === 0 ? 0n : BigInt("0x" + bytesToHex(bytes)); +} + +function toBlock(block?: BlockOptions): RunCallOpts["block"] { + if (!block) { + return undefined; + } + const { prevrandao = 0n, blobBaseFee = 1n } = block; + return { + header: { + number: block.number ?? 0n, + coinbase: toAddress(block.coinbase ?? "00".repeat(20)), + timestamp: block.timestamp ?? 0n, + difficulty: 0n, + prevRandao: + typeof prevrandao === "bigint" + ? toWord(prevrandao) + : hexToBytes(prevrandao), + gasLimit: block.gasLimit ?? 30_000_000n, + baseFeePerGas: block.baseFee ?? 0n, + getBlobGasPrice: () => blobBaseFee, + }, + }; +} + +function toExecutionResult(raw: ResultWithExec): ExecutionResult { + const execResult = (raw.execResult || raw) as ExecResult; + return { + success: execResult.exceptionError === undefined, + gasUsed: execResult.executionGasUsed || 0n, + returnValue: execResult.returnValue || new Uint8Array(), + logs: execResult.logs || [], + error: execResult.exceptionError, + }; +} diff --git a/packages/evm/src/index.ts b/packages/evm/src/index.ts index b64b44e837..a06dbf07cb 100644 --- a/packages/evm/src/index.ts +++ b/packages/evm/src/index.ts @@ -10,28 +10,50 @@ * * @example * ```typescript - * import { Executor, createMachineState } from "@ethdebug/evm"; + * import { Executor, createTrace, createMachineState } from "@ethdebug/evm"; * import { dereference } from "@ethdebug/pointers"; * - * // Create executor and deploy contract * const executor = new Executor(); - * await executor.deploy(bytecode); - * await executor.execute(); + * await executor.fund(alice, 10n ** 18n); + * const { address } = await executor.deploy({ from: alice, create }); * - * // Create machine state for pointer evaluation - * const state = createMachineState(executor); + * // Record a transaction, then evaluate a pointer at one of its steps + * const trace = createTrace({ memory: "changed" }); + * await executor.call({ from: alice, to: address!, input }, trace); + * const state = createMachineState(trace.stateAt(i)); * const cursor = await dereference(pointer, { state }); * ``` */ // Executor export { Executor } from "#executor"; -export type { ExecutionOptions, ExecutionResult } from "#executor"; +export type { + ExecutorOptions, + ExecutionOptions, + ExecutionResult, + BlockOptions, + DeployOptions, + DeployResult, + CallOptions, +} from "#executor"; // Machine state adapter export { createMachineState } from "#machine"; export type { MachineStateOptions } from "#machine"; -// Trace types and Machine -export { createTraceCollector, createMachine } from "#trace"; -export type { TraceStep, TraceHandler, Trace } from "#trace"; +// Traces and Machine +export { createTrace, createMachine } from "#trace"; +export type { + TraceStep, + StepState, + StepStateWithoutMemory, + TraceHandler, + TraceOptions, + RecordOptions, + Trace, + TraceFrame, + MemoryPolicy, + MessageFrame, + FrameEvent, + FrameHandler, +} from "#trace"; diff --git a/packages/evm/src/machine.test.ts b/packages/evm/src/machine.test.ts index 7230f5583f..29fa04e9d0 100644 --- a/packages/evm/src/machine.test.ts +++ b/packages/evm/src/machine.test.ts @@ -1,203 +1,116 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { Data } from "@ethdebug/pointers"; import { Executor } from "#executor"; import { createMachineState } from "#machine"; +import type { StepState } from "#trace"; // Constructor that deploys: PUSH1 0x2a PUSH1 0x00 SSTORE STOP const constructorCode = "65602a600055006000526006601af3"; -describe("createMachineState", () => { - let executor: Executor; - - beforeEach(async () => { - executor = new Executor(); - await executor.deploy(constructorCode); - await executor.execute(); - }); +const memory = new Uint8Array(64); +memory[31] = 0xff; +memory[63] = 0xab; - describe("end-state (no traceStep)", () => { - it("reads storage", async () => { - const state = createMachineState(executor); - const val = await state.storage.read({ - slot: Data.fromUint(0n), - }); - expect(val.asUint()).toBe(42n); - }); +const stepState: StepState = { + stack: [100n, 200n, 0xdeadbeefn], + memory, + storage: async (slot) => (slot === 1n ? 7n : 0n), + transient: async (slot) => (slot === 2n ? 9n : 0n), + calldata: new Uint8Array([1, 2, 3, 4]), + returndata: new Uint8Array([5, 6]), + code: new Uint8Array([0x60, 0x2a]), +}; - it("reads storage with slice", async () => { - const state = createMachineState(executor); - const val = await state.storage.read({ - slot: Data.fromUint(0n), - slice: { offset: 31n, length: 1n }, - }); - // 42 = 0x2a, in a 32-byte big-endian word the - // last byte is at offset 31 - expect(val.asUint()).toBe(42n); - }); - - it("returns zero for stack", async () => { - const state = createMachineState(executor); - expect(await state.stack.length).toBe(0n); - const val = await state.stack.peek({ depth: 0n }); - expect(val.asUint()).toBe(0n); - }); - - it("returns zero for memory", async () => { - const state = createMachineState(executor); - expect(await state.memory.length).toBe(0n); - const val = await state.memory.read({ - slice: { offset: 0n, length: 32n }, - }); - expect(val.asUint()).toBe(0n); - }); - - it("uses default context values", async () => { - const state = createMachineState(executor); - expect(await state.programCounter).toBe(0n); - expect(await state.opcode).toBe("STOP"); - expect(await state.traceIndex).toBe(0n); - }); - - it("accepts context overrides", async () => { - const state = createMachineState(executor, { - programCounter: 10n, - opcode: "SLOAD", - traceIndex: 5n, - }); - expect(await state.programCounter).toBe(10n); - expect(await state.opcode).toBe("SLOAD"); - expect(await state.traceIndex).toBe(5n); - }); - - it("reads code length", async () => { - const state = createMachineState(executor); - const len = await state.code.length; - // Deployed runtime is 6 bytes (storeValueCode) - expect(len).toBe(6n); - }); - - it("reads code bytes", async () => { - const state = createMachineState(executor); - const data = await state.code.read({ - slice: { offset: 0n, length: 1n }, - }); - // First byte of "602a60005500" is 0x60 (PUSH1) - expect(data.asUint()).toBe(0x60n); +describe("createMachineState", () => { + it("reads the stack, top first", async () => { + const state = createMachineState(stepState); + expect(await state.stack.length).toBe(3n); + expect((await state.stack.peek({ depth: 0n })).asUint()).toBe(0xdeadbeefn); + expect((await state.stack.peek({ depth: 2n })).asUint()).toBe(100n); + expect((await state.stack.peek({ depth: 5n })).asUint()).toBe(0n); + const sliced = await state.stack.peek({ + depth: 0n, + slice: { offset: 28n, length: 4n }, }); + expect(sliced.asUint()).toBe(0xdeadbeefn); }); - describe("with traceStep", () => { - it("reads stack from trace step", async () => { - const state = createMachineState(executor, { - traceStep: { - pc: 0, - opcode: "SSTORE", - stack: [100n, 200n, 300n], - }, - }); - - expect(await state.stack.length).toBe(3n); - - // depth 0 = top of stack = last element - const top = await state.stack.peek({ depth: 0n }); - expect(top.asUint()).toBe(300n); - - // depth 1 = second from top - const second = await state.stack.peek({ - depth: 1n, - }); - expect(second.asUint()).toBe(200n); - - // depth 2 = bottom - const bottom = await state.stack.peek({ - depth: 2n, - }); - expect(bottom.asUint()).toBe(100n); - }); - - it("returns zero for out-of-bounds depth", async () => { - const state = createMachineState(executor, { - traceStep: { - pc: 0, - opcode: "PUSH1", - stack: [42n], - }, - }); + it("reads memory, zero past its end", async () => { + const state = createMachineState(stepState); + expect(await state.memory.length).toBe(64n); + const read = (offset: bigint) => + state.memory.read({ slice: { offset, length: 32n } }); + expect((await read(0n)).asUint()).toBe(0xffn); + expect((await read(32n)).asUint()).toBe(0xabn); + expect((await read(64n)).asUint()).toBe(0n); + }); - const val = await state.stack.peek({ depth: 5n }); - expect(val.asUint()).toBe(0n); + it("reads storage and transient storage, with slices", async () => { + const state = createMachineState(stepState); + const slot = (n: bigint) => Data.fromUint(n); + expect((await state.storage.read({ slot: slot(1n) })).asUint()).toBe(7n); + const byte = await state.storage.read({ + slot: slot(1n), + slice: { offset: 31n, length: 1n }, }); + expect(byte.asUint()).toBe(7n); + expect((await state.transient.read({ slot: slot(2n) })).asUint()).toBe(9n); + }); - it("supports stack peek with slice", async () => { - const state = createMachineState(executor, { - traceStep: { - pc: 0, - opcode: "PUSH1", - stack: [0xdeadbeefn], - }, - }); - - // 0xdeadbeef padded to 32 bytes, last 4 bytes - const val = await state.stack.peek({ - depth: 0n, - slice: { offset: 28n, length: 4n }, - }); - expect(val.asUint()).toBe(0xdeadbeefn); + it("reads calldata, returndata and code", async () => { + const state = createMachineState(stepState); + expect(await state.calldata.length).toBe(4n); + const calldata = await state.calldata.read({ + slice: { offset: 2n, length: 2n }, }); + expect(calldata.asUint()).toBe(0x0304n); + expect(await state.returndata.length).toBe(2n); + const code = await state.code.read({ slice: { offset: 0n, length: 1n } }); + expect(code.asUint()).toBe(0x60n); + }); - it("reads memory from trace step", async () => { - const mem = new Uint8Array(64); - mem[31] = 0xff; - mem[63] = 0xab; - - const state = createMachineState(executor, { - traceStep: { - pc: 0, - opcode: "MLOAD", - stack: [], - memory: mem, - }, - }); - - expect(await state.memory.length).toBe(64n); - - const word1 = await state.memory.read({ - slice: { offset: 0n, length: 32n }, - }); - expect(word1.asUint()).toBe(0xffn); - - const word2 = await state.memory.read({ - slice: { offset: 32n, length: 32n }, - }); - expect(word2.asUint()).toBe(0xabn); + it("takes the pc, opcode and trace index", async () => { + const state = createMachineState(stepState, { + pc: 10, + opcode: "SLOAD", + traceIndex: 5, }); + expect(await state.programCounter).toBe(10n); + expect(await state.opcode).toBe("SLOAD"); + expect(await state.traceIndex).toBe(5n); + + const defaults = createMachineState(stepState); + expect(await defaults.programCounter).toBe(0n); + expect(await defaults.opcode).toBe("STOP"); + expect(await defaults.traceIndex).toBe(0n); + }); - it("derives pc/opcode from trace step", async () => { - const state = createMachineState(executor, { - traceStep: { - pc: 42, - opcode: "JUMPDEST", - stack: [], - }, - }); - - expect(await state.programCounter).toBe(42n); - expect(await state.opcode).toBe("JUMPDEST"); - }); + it("takes only a complete state (a type check)", () => { + const { storage: _, ...partial } = stepState; + const create = () => + // @ts-expect-error: a state without storage + createMachineState(partial); + expect(create).toBeTypeOf("function"); + }); +}); - it("allows overriding trace step context", async () => { - const state = createMachineState(executor, { - traceStep: { - pc: 42, - opcode: "JUMPDEST", - stack: [], - }, - programCounter: 99n, - opcode: "STOP", - }); +describe("Executor.currentState", () => { + it("reads the executor's storage and code now", async () => { + const executor = new Executor(); + await executor.deploy(constructorCode); + await executor.execute(); - expect(await state.programCounter).toBe(99n); - expect(await state.opcode).toBe("STOP"); - }); + const state = createMachineState(await executor.currentState()); + const value = await state.storage.read({ slot: Data.fromUint(0n) }); + expect(value.asUint()).toBe(42n); + expect(await state.code.length).toBe(6n); + expect(await state.stack.length).toBe(0n); + expect(await state.memory.length).toBe(0n); + expect(await state.calldata.length).toBe(0n); + + // it reads live: a later change shows + await executor.setStorage(0n, 43n); + const later = await state.storage.read({ slot: Data.fromUint(0n) }); + expect(later.asUint()).toBe(43n); }); }); diff --git a/packages/evm/src/machine.ts b/packages/evm/src/machine.ts index 6ffcd7055c..3348550deb 100644 --- a/packages/evm/src/machine.ts +++ b/packages/evm/src/machine.ts @@ -1,144 +1,117 @@ /** * Machine.State Adapter * - * Implements the @ethdebug/pointers Machine.State interface - * to enable pointer evaluation against EVM executor state. + * Implements the @ethdebug/pointers Machine.State interface over a + * complete step state, to enable pointer evaluation at a trace step. */ import type { Machine } from "@ethdebug/pointers"; import { Data } from "@ethdebug/pointers"; -import type { Executor } from "#executor"; -import type { TraceStep } from "#trace"; +import type { StepState } from "#trace"; /** - * Options for creating a Machine.State adapter. + * What ran at the step, for a Machine.State. Each defaults to the + * state's own (`state.at`, from a trace), else to 0, "STOP" and 0. */ export interface MachineStateOptions { - /** A captured trace step to read stack/memory from */ - traceStep?: TraceStep; - /** Program counter (overrides traceStep.pc if set) */ - programCounter?: bigint; - /** Opcode (overrides traceStep.opcode if set) */ + pc?: number | bigint; + /** Opcode name */ opcode?: string; /** Trace index (step number) */ - traceIndex?: bigint; + traceIndex?: number | bigint; } /** - * Create a Machine.State from an Executor. + * Create a Machine.State that reads the given step state, and only + * it: get one from `Trace.stateAt` for a step of a trace, or from + * `Executor.currentState` for the executor's state now. * - * When a traceStep is provided, stack and memory reads - * use the captured step data. Without a traceStep, only - * storage is functional (end-state adapter). - * - * @param executor - EVM executor to read storage/code from - * @param options - Trace step and context overrides + * @param state - The complete state at the step + * @param options - Program counter, opcode and trace index */ export function createMachineState( - executor: Executor, + state: StepState, options: MachineStateOptions = {}, ): Machine.State { - const { traceStep, traceIndex = 0n } = options; - - const programCounter = - options.programCounter ?? (traceStep ? BigInt(traceStep.pc) : 0n); - const opcode = options.opcode ?? (traceStep ? traceStep.opcode : "STOP"); + const { + pc = state.at?.pc ?? 0, + opcode = state.at?.opcode ?? "STOP", + traceIndex = state.at?.traceIndex ?? 0, + } = options; + const { stack } = state; return { - traceIndex: Promise.resolve(traceIndex), - programCounter: Promise.resolve(programCounter), + traceIndex: Promise.resolve(BigInt(traceIndex)), + programCounter: Promise.resolve(BigInt(pc)), opcode: Promise.resolve(opcode), stack: { - length: Promise.resolve(traceStep ? BigInt(traceStep.stack.length) : 0n), + length: Promise.resolve(BigInt(stack.length)), async peek({ depth, slice }): Promise { - if (!traceStep) { - return Data.zero(); - } - - const { stack } = traceStep; const index = stack.length - 1 - Number(depth); if (index < 0 || index >= stack.length) { return Data.zero(); } - - const data = Data.fromUint(stack[index]).padUntilAtLeast(32); - - if (slice) { - const sliced = new Uint8Array(data).slice( - Number(slice.offset), - Number(slice.offset + slice.length), - ); - return Data.fromBytes(sliced); - } - - return data; + return sliceWord(stack[index], slice); }, }, - memory: { - length: Promise.resolve( - traceStep?.memory ? BigInt(traceStep.memory.length) : 0n, - ), - async read({ slice }): Promise { - if (!traceStep?.memory) { - return Data.zero(); - } - - const sliced = traceStep.memory.slice( - Number(slice.offset), - Number(slice.offset + slice.length), - ); - return Data.fromBytes(sliced); - }, - }, + memory: bytesRegion(state.memory), storage: { async read({ slot, slice }): Promise { - const slotValue = slot.asUint(); - const value = await executor.getStorage(slotValue); - const data = Data.fromUint(value); - - if (slice) { - const padded = data.padUntilAtLeast(32); - const sliced = new Uint8Array(padded).slice( - Number(slice.offset), - Number(slice.offset + slice.length), - ); - return Data.fromBytes(sliced); - } - - return data.padUntilAtLeast(32); + return sliceWord(await state.storage(slot.asUint()), slice); }, }, - calldata: { - length: Promise.resolve(0n), - read: async (): Promise => Data.zero(), - }, + calldata: bytesRegion(state.calldata), - returndata: { - length: Promise.resolve(0n), - read: async (): Promise => Data.zero(), - }, + returndata: bytesRegion(state.returndata), + + code: bytesRegion(state.code), - code: { - length: (async () => { - const code = await executor.getCode(); - return BigInt(code.length); - })(), - async read({ slice }): Promise { - const code = await executor.getCode(); - const sliced = code.slice( - Number(slice.offset), - Number(slice.offset + slice.length), - ); - return Data.fromBytes(sliced); + transient: { + async read({ slot, slice }): Promise { + return sliceWord(await state.transient(slot.asUint()), slice); }, }, + }; +} - transient: { - read: async (): Promise => Data.zero(), +interface Slice { + offset: bigint; + length: bigint; +} + +function sliceWord(value: bigint, slice?: Slice): Data { + const padded = Data.fromUint(value).padUntilAtLeast(32); + if (!slice) { + return padded; + } + return Data.fromBytes( + new Uint8Array(padded).slice( + Number(slice.offset), + Number(slice.offset + slice.length), + ), + ); +} + +/** + * A byte region (memory, calldata, returndata, code) over the given + * bytes. Reads past the end are zero, as in the EVM. + */ +function bytesRegion(bytes: Uint8Array): { + length: Promise; + read(options: { slice: Slice }): Promise; +} { + return { + length: Promise.resolve(BigInt(bytes.length)), + async read({ slice }): Promise { + const offset = Number(slice.offset); + const length = Number(slice.length); + const out = new Uint8Array(length); + out.set(bytes.subarray(offset, offset + length)); + return Data.fromBytes(out); }, }; } diff --git a/packages/evm/src/trace.test.ts b/packages/evm/src/trace.test.ts index 4d7144de5f..5f69afd06b 100644 --- a/packages/evm/src/trace.test.ts +++ b/packages/evm/src/trace.test.ts @@ -1,74 +1,402 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { bytesToHex, utf8ToBytes } from "ethereum-cryptography/utils"; +import { sha256 } from "ethereum-cryptography/sha256"; import { Executor } from "#executor"; -import { createTraceCollector, createMachine } from "#trace"; +import { createMachineState } from "#machine"; +import { createTrace, createMachine } from "#trace"; +import type { FrameEvent, MemoryPolicy, Trace } from "#trace"; +import { asm, creation, word } from "../test/bytecode.js"; +import { + alice, + bob, + carol, + deployed, + forward, + funded, + recurse, + store, +} from "../test/contracts.js"; // Constructor that deploys: PUSH1 0x2a PUSH1 0x00 SSTORE STOP const constructorCode = "65602a600055006000526006601af3"; -describe("createTraceCollector", () => { - it("collects trace steps during execution", async () => { - const executor = new Executor(); - await executor.deploy(constructorCode); - - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); - const trace = getTrace(); +/** Index of the `n`th step (from 0) matching, or -1 */ +const find = ( + trace: Trace, + match: (step: Trace["steps"][number]) => boolean, + n = 0, +) => { + let seen = 0; + return trace.steps.findIndex((step) => match(step) && seen++ === n); +}; - expect(trace.steps.length).toBeGreaterThan(0); +describe("createTrace", () => { + let executor: Executor; - // First step should be at PC 0 - expect(trace.steps[0].pc).toBe(0); - expect(trace.steps[0].opcode).toBeDefined(); - expect(trace.steps[0].stack).toBeInstanceOf(Array); + beforeEach(async () => { + executor = await funded(); }); - it("captures memory when available", async () => { - const executor = new Executor(); - // Use bytecode that writes to memory: - // PUSH1 0x42 PUSH1 0x00 MSTORE STOP - // = 60 42 60 00 52 00 - const memCode = "604260005200"; - const result = await executor.executeCode(memCode); + it("gives the state at past steps across frames and reverts", async () => { + const address = await deployed(executor, recurse); + const trace = createTrace(); + const result = await executor.call( + { from: alice, to: address, input: word(0n) }, + trace, + ); expect(result.success).toBe(true); - // Now deploy something and trace with memory - // Constructor: PUSH6 PUSH1 0 MSTORE - // PUSH1 6 PUSH1 0x1a RETURN - // where runtime = 604260005200 (MSTORE bytecode) - const memConstructor = "656042600052006000526006601af3"; - await executor.deploy(memConstructor); + expect( + trace.frames.map(({ depth, reverted }) => [depth, reverted]), + ).toEqual([ + [0, false], + [1, true], + [2, false], + ]); + expect(trace.frames[2].parent).toBe(1); + + const slots = async (index: number) => { + const { storage } = trace.stateAt(index); + return Promise.all([0n, 1n, 2n].map((slot) => storage(slot))); + }; + const at = (depth: number, opcode: string, n = 0) => + find(trace, (step) => step.depth === depth && step.opcode === opcode, n); + + // at depth 2, after its SSTORE, every frame's write holds + expect(await slots(at(2, "SSTORE") + 1)).toEqual([0x10n, 0x11n, 0x12n]); + // back at depth 1, before it reverts + const revert = at(1, "REVERT"); + expect(await slots(revert)).toEqual([0x10n, 0x11n, 0x12n]); + // back at depth 0, depth 1's frame (and depth 2's in it) is undone + const after = at(0, "POP"); + expect(await slots(after)).toEqual([0x10n, 0n, 0n]); + // before anything ran + expect(await slots(0)).toEqual([0n, 0n, 0n]); - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); - const trace = getTrace(); + // calldata is the frame's; return data is the last call's + const atDepth1 = trace.stateAt(revert); + expect(bytesToHex(atDepth1.calldata)).toBe(word(1n)); + expect(atDepth1.returndata).toHaveLength(0); + expect(bytesToHex(trace.stateAt(after).calldata)).toBe(word(0n)); + expect(bytesToHex(trace.stateAt(after).returndata)).toBe(word(2n)); + expect(bytesToHex(trace.stateAt(after).code)).toBe(recurse); - // The runtime does MSTORE, so memory should grow - const hasMemory = trace.steps.some((s) => s.memory && s.memory.length > 0); - expect(hasMemory).toBe(true); + // the committed end state agrees + expect(await executor.getStorage(1n, address)).toBe(0n); }); - it("captures gas remaining", async () => { - const executor = new Executor(); - await executor.deploy(constructorCode); + it("feeds createMachineState", async () => { + const address = await deployed(executor, store); + const trace = createTrace(); + await executor.call({ from: alice, to: address, input: word(7n) }, trace); + // a later transaction does not change the trace's states + await executor.call({ from: alice, to: address, input: word(9n) }); + + const sstore = find(trace, (step) => step.opcode === "SSTORE"); + const { pc, opcode } = trace.steps[sstore]; + const state = createMachineState(trace.stateAt(sstore), { + pc, + opcode, + traceIndex: sstore, + }); + expect(await state.opcode).toBe("SSTORE"); + const { Data } = await import("@ethdebug/pointers"); + const slot0 = await state.storage.read({ slot: Data.fromUint(0n) }); + expect(slot0.asUint()).toBe(0n); + const calldata = await state.calldata.read({ + slice: { offset: 0n, length: 32n }, + }); + expect(calldata.asUint()).toBe(7n); + expect((await state.stack.peek({ depth: 1n })).asUint()).toBe(7n); - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); - const trace = getTrace(); + // a trace's state knows its step + const own = createMachineState(trace.stateAt(sstore)); + expect(await own.traceIndex).toBe(BigInt(sstore)); + expect(await own.programCounter).toBe(BigInt(pc)); + expect(await own.opcode).toBe("SSTORE"); - expect(trace.steps[0].gasRemaining).toBeGreaterThan(0n); + const next = trace.stateAt(sstore + 1); + expect(await next.storage(0n)).toBe(7n); }); - it("returns independent copies", async () => { - const executor = new Executor(); - await executor.deploy(constructorCode); + it("reads untouched storage only while the executor is unchanged", async () => { + const address = await deployed(executor, store); + await executor.setStorage(5n, 55n, address); + const trace = createTrace(); + await executor.call({ from: alice, to: address, input: word(7n) }, trace); + expect(await trace.stateAt(0).storage(5n)).toBe(55n); + + await executor.call({ from: alice, to: address, input: word(8n) }); + await expect(trace.stateAt(0).storage(5n)).rejects.toThrow("not touched"); + // touched slots still read + expect(await trace.stateAt(0).storage(0n)).toBe(0n); + }); + + it("records one transaction", async () => { + const address = await deployed(executor, store); + const trace = createTrace(); + await executor.call({ from: alice, to: address }, trace); + await expect( + executor.call({ from: alice, to: address }, trace), + ).rejects.toThrow("one transaction"); + }); + + it("reads transient storage at a step", async () => { + const address = await deployed( + executor, + asm(`PUSH1 42 PUSH1 1 TSTORE STOP`), + ); + const trace = createTrace(); + await executor.call({ from: alice, to: address }, trace); + const tstore = find(trace, (step) => step.opcode === "TSTORE"); + expect(await trace.stateAt(tstore).transient(1n)).toBe(0n); + expect(await trace.stateAt(tstore + 1).transient(1n)).toBe(42n); + }); + + it("undoes transient storage written in a reverted frame", async () => { + // TSTORE(1, 0x20 + n); for n = 0, calls itself with 1; for n = 1, + // reverts + const address = await deployed( + executor, + asm(`PUSH1 0 CALLDATALOAD + DUP1 PUSH1 32 ADD PUSH1 1 TSTORE + DUP1 @child JUMPI + PUSH1 1 PUSH1 0 MSTORE + PUSH1 0 PUSH1 0 PUSH1 32 PUSH1 0 PUSH1 0 ADDRESS GAS CALL POP + STOP + child: PUSH1 0 PUSH1 0 REVERT`), + ); + const trace = createTrace(); + await executor.call({ from: alice, to: address, input: word(0n) }, trace); + expect(trace.frames.map(({ reverted }) => reverted)).toEqual([false, true]); + + const revert = find(trace, (step) => step.opcode === "REVERT"); + expect(await trace.stateAt(revert).transient(1n)).toBe(0x21n); + const after = find(trace, (step) => step.opcode === "POP"); + expect(await trace.stateAt(after).transient(1n)).toBe(0x20n); + }); + + it("clears return data at a call that fails before it starts", async () => { + const other = await deployed(executor, store); + // CALL other (returns a word); then CALL other with 1 wei, which + // the contract does not have + const address = await deployed( + executor, + asm(`PUSH1 0 PUSH1 0 PUSH1 0 PUSH1 0 PUSH1 0 PUSH20 ${other} GAS + CALL POP + PUSH1 0 PUSH1 0 PUSH1 0 PUSH1 0 PUSH1 1 PUSH20 ${other} GAS + CALL POP + STOP`), + ); + const trace = createTrace(); + await executor.call({ from: alice, to: address }, trace); + const pop = (n: number) => + find(trace, (step) => step.opcode === "POP" && step.depth === 0, n); + expect(trace.stateAt(pop(0)).returndata).toHaveLength(32); + expect(trace.stateAt(pop(1)).returndata).toHaveLength(0); + }); - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); + it("types a trace's state by its memory policy", () => { + // type checks only: these functions do not run + const literal = () => createMachineState(createTrace().stateAt(0)); + const changed = () => + createMachineState(createTrace({ memory: "changed" }).stateAt(0)); + const general = (memory: MemoryPolicy) => + // @ts-expect-error: a policy that may be "none" may give no memory + createMachineState(createTrace({ memory }).stateAt(0)); + const none = () => + // @ts-expect-error: no memory + createMachineState(createTrace({ memory: "none" }).stateAt(0)); + expect([literal, changed, general, none]).toHaveLength(4); + }); + + it("reports DELEGATECALL frames", async () => { + const library = await deployed(executor, store); + // DELEGATECALL(GAS, library, 0, CALLDATASIZE, 0, 0) with the calldata + const proxy = await deployed( + executor, + asm(`CALLDATASIZE PUSH1 0 PUSH1 0 CALLDATACOPY + PUSH1 0 PUSH1 0 CALLDATASIZE PUSH1 0 PUSH20 ${library} GAS + DELEGATECALL STOP`), + ); + const trace = createTrace(); + await executor.call({ from: carol, to: proxy, input: word(5n) }, trace); + + const [, frame] = trace.frames; + expect(frame).toMatchObject({ + depth: 1, + address: proxy, + codeAddress: library, + caller: carol, + delegatecall: true, + }); + const sstore = find(trace, (step) => step.opcode === "SSTORE"); + expect(trace.steps[sstore]).toMatchObject({ + op: 0x55, + depth: 1, + address: proxy, + codeAddress: library, + }); + expect(await trace.stateAt(sstore + 1).storage(0n)).toBe(5n); + expect(bytesToHex(trace.stateAt(sstore).code)).toBe(store); + expect(await executor.getStorage(0n, proxy)).toBe(5n); + expect(await executor.getStorage(0n, library)).toBe(0n); + }); + + it("reports CREATE and CREATE2 frames", async () => { + // CREATE and CREATE2 (salt 0x42) of the init code in the calldata; + // stores the two addresses at slots 0 and 1 + const factory = await deployed( + executor, + asm(`CALLDATASIZE PUSH1 0 PUSH1 0 CALLDATACOPY + CALLDATASIZE PUSH1 0 PUSH1 0 CREATE PUSH1 0 SSTORE + PUSH1 0x42 CALLDATASIZE PUSH1 0 PUSH1 0 CREATE2 PUSH1 1 SSTORE + STOP`), + ); + const events: FrameEvent[] = []; + const trace = createTrace({ frame: (event) => events.push(event) }); + const result = await executor.call( + { from: alice, to: factory, input: creation(store) }, + trace, + ); + expect(result.success).toBe(true); + + const created = await executor.getStorage(0n, factory); + const created2 = await executor.getStorage(1n, factory); + const hex = (value: bigint) => "0x" + word(value, 20); + expect( + events.map(({ kind, frame }) => [kind, frame.depth, frame.address]), + ).toEqual([ + ["enter", 0, factory], + ["enter", 1, hex(created)], + ["exit", 1, hex(created)], + ["enter", 1, hex(created2)], + ["exit", 1, hex(created2)], + ["exit", 0, factory], + ]); + expect(trace.frames[2]).toMatchObject({ create: true, reverted: false }); + + // frame events carry their first and last step indexes + const enter2 = events[3]; + const exit2 = events[4]; + expect(enter2.kind === "enter" && enter2.first).toBe(trace.frames[2].first); + expect(exit2.kind === "exit" && exit2.last).toBe(trace.frames[2].end - 1); + expect(trace.steps[trace.frames[2].first].depth).toBe(1); + expect(trace.steps[trace.frames[2].end].depth).toBe(0); + expect(bytesToHex(trace.frames[2].initcode!)).toBe(creation(store)); + + const inCreate2 = trace.steps.slice( + trace.frames[2].first, + trace.frames[2].end, + ); + expect(inCreate2.length).toBeGreaterThan(0); + for (const step of inCreate2) { + expect(step.address).toBe(hex(created2)); + } + // after a successful create, there is no return data + const sstore = find(trace, (step) => step.opcode === "SSTORE", 1); + expect(trace.stateAt(sstore).returndata).toHaveLength(0); + expect(bytesToHex(await executor.getCode(hex(created2)))).toBe(store); + }); + + describe("memory", () => { + // writes memory with every kind of instruction that can + const memoryCode = (other: string) => + asm(` + PUSH1 0xaa PUSH1 3 MSTORE8 + PUSH1 4 PUSH1 0 PUSH1 64 CALLDATACOPY + PUSH1 8 PUSH1 0 PUSH1 96 CODECOPY + PUSH1 8 PUSH1 0 PUSH1 128 PUSH20 ${other} EXTCODECOPY + PUSH1 32 PUSH1 160 PUSH1 32 PUSH1 64 PUSH1 0 PUSH20 ${other} GAS + CALL POP + PUSH1 32 PUSH1 0 PUSH1 192 RETURNDATACOPY + PUSH1 32 PUSH1 160 PUSH1 224 MCOPY + PUSH1 32 PUSH1 0 KECCAK256 POP + PUSH2 512 MLOAD POP + STOP + `); + + const traceWith = async (trace: Trace) => { + const executor = await funded(); + const other = await deployed(executor, store); + const address = await deployed(executor, memoryCode(other)); + await executor.call( + { from: alice, to: address, input: word(0x1234n) }, + trace, + ); + return trace; + }; + + it("records none", async () => { + const trace = await traceWith(createTrace({ memory: "none" })); + const state = trace.stateAt(0); + expect("memory" in state).toBe(false); + // @ts-expect-error: a state without memory + expect(() => createMachineState(state)).toThrow(); + }); + + it("copies at every step with full", async () => { + const trace = await traceWith(createTrace({ memory: "full" })); + const arrays = new Set( + trace.steps.map((_, i) => trace.stateAt(i).memory), + ); + expect(arrays.size).toBe(trace.steps.length); + }); + + it("shares unchanged memory with changed", async () => { + const full = await traceWith(createTrace({ memory: "full" })); + const changed = await traceWith(createTrace({ memory: "changed" })); + const memories = (trace: typeof full) => + trace.steps.map((_, i) => bytesToHex(trace.stateAt(i).memory)); + expect(memories(changed)).toEqual(memories(full)); + expect(new Set(memories(full)).size).toBeGreaterThan(8); + + const arrays = new Set( + changed.steps.map((_, i) => changed.stateAt(i).memory), + ); + expect(arrays.size).toBeLessThan(changed.steps.length / 2); + }); + }); + + describe("determinism", () => { + const digest = async () => { + const executor = await funded(new Executor({ chainId: 1n })); + const records: unknown[] = []; + const record = (trace: Trace) => { + records.push(trace.steps, trace.frames); + trace.steps.forEach((_, i) => { + const { stack, memory, calldata, returndata } = trace.stateAt(i); + records.push(stack, memory, calldata, returndata); + }); + }; + const inner = await deployed(executor, recurse); + const outer = await deployed(executor, forward(inner)); + for (const [i, from] of [carol, alice, bob].entries()) { + const trace = createTrace({ memory: "changed" }); + await executor.call( + { + from, + to: i === 1 ? inner : outer, + input: word(BigInt(i)), + block: { number: BigInt(i + 1), prevrandao: BigInt(i * 99) }, + }, + trace, + ); + record(trace); + } + const json = JSON.stringify(records, (_, value) => + typeof value === "bigint" + ? value.toString() + : value instanceof Uint8Array + ? bytesToHex(value) + : value, + ); + return bytesToHex(sha256(utf8ToBytes(json))); + }; - const trace1 = getTrace(); - const trace2 = getTrace(); - expect(trace1.steps).toEqual(trace2.steps); - expect(trace1.steps).not.toBe(trace2.steps); + it("gives the same digest for the same inputs", async () => { + expect(await digest()).toBe(await digest()); + }); }); }); diff --git a/packages/evm/src/trace.ts b/packages/evm/src/trace.ts index 13516839e5..61da6e9ab2 100644 --- a/packages/evm/src/trace.ts +++ b/packages/evm/src/trace.ts @@ -1,59 +1,482 @@ /** - * EVM Execution Trace Types + * EVM Execution Traces * - * Types for capturing and representing EVM execution traces. + * A trace step is an event: which instruction ran, and where. The state + * at a step is separate: a recorded `Trace` gives the complete + * `StepState` at any of its steps. */ import type { Machine } from "@ethdebug/pointers"; -import type { Executor } from "#executor"; -import type { ExecutionOptions } from "#executor"; +import type { Executor, ExecutionOptions } from "#executor"; import { createMachineState } from "#machine"; /** - * A single step in an execution trace. + * One executed instruction: what ran and where. The state before it + * runs is `Trace.stateAt(index)`. */ export interface TraceStep { /** Program counter */ pc: number; /** Opcode name (e.g., "PUSH1", "SLOAD") */ opcode: string; - /** Stack state at this step */ - stack: bigint[]; - /** Memory state at this step (optional) */ - memory?: Uint8Array; - /** Gas remaining (optional) */ - gasRemaining?: bigint; + /** Opcode byte (e.g., 0x60, 0x54) */ + op: number; + /** Gas remaining before the instruction */ + gasRemaining: bigint; + /** Gas cost of the instruction (static + dynamic fee) */ + gasCost: bigint; + /** Message depth: 0 for the transaction's own message */ + depth: number; + /** Address whose storage the step's frame uses */ + address: string; + /** Address whose code the step's frame runs */ + codeAddress: string; } +/** + * The complete machine state at a trace step: the state before the + * step's instruction runs. Every part is required. + */ +export interface StepState { + /** The stack, bottom first (the top is the last element) */ + stack: readonly bigint[]; + memory: Uint8Array; + /** Storage of the step's contract, by slot */ + storage(slot: bigint): Promise; + /** Transient storage of the step's contract, by slot */ + transient(slot: bigint): Promise; + /** The frame's calldata */ + calldata: Uint8Array; + /** Return data of the frame's last completed call or create */ + returndata: Uint8Array; + /** The code the frame runs */ + code: Uint8Array; + /** + * Where the state is in a trace, for `createMachineState`; absent + * for a state that is not a trace step's + */ + at?: { traceIndex: number; pc: number; opcode: string }; +} + +/** + * The state at a step of a trace recorded without memory. + */ +export type StepStateWithoutMemory = Omit; + /** * Handler function for trace steps during execution. */ -export type TraceHandler = (step: TraceStep) => void; +export type TraceHandler = (step: TraceStep, index: number) => void; + +/** + * How a trace records memory at each step: + * - "none": no memory (`stateAt` gives no `memory`); + * - "full": a new copy at every step; + * - "changed": a new copy only when memory changed since the frame's + * previous step; otherwise the step shares that step's copy. + */ +export type MemoryPolicy = "none" | "full" | "changed"; /** - * A complete execution trace. + * One message (a call or a create) of a transaction. */ -export interface Trace { - /** All steps in the trace */ - steps: TraceStep[]; +export interface MessageFrame { + /** Message depth: 0 for the transaction's own message */ + depth: number; + /** Address whose storage the frame uses (the new contract's address + * for a create) */ + address: string; + /** Address whose code the frame runs */ + codeAddress: string; + /** The message's sender */ + caller: string; + /** Input data (empty for a create) */ + calldata: Uint8Array; + /** Init code, for a create */ + initcode?: Uint8Array; + /** Value sent with the message */ + value: bigint; + /** Whether the message is a create */ + create: boolean; + /** Whether the message is a DELEGATECALL */ + delegatecall: boolean; + /** Whether the message is static (STATICCALL) */ + static: boolean; } /** - * Create a trace handler that collects steps into a - * Trace object. - * - * @returns [handler, getTrace] tuple + * A message frame starts ("enter") or ends ("exit"). An enter carries + * the index of the frame's first trace step; an exit, the index of + * its last (one less than its first, for a frame with no steps, as a + * precompile's), the message's return data (for a create: the deployed + * code) and whether the message reverted, so its state changes were + * undone. */ -export function createTraceCollector(): [TraceHandler, () => Trace] { +export type FrameEvent = + | { kind: "enter"; frame: MessageFrame; first: number } + | { + kind: "exit"; + frame: MessageFrame; + last: number; + returnData: Uint8Array; + reverted: boolean; + error?: string; + }; + +/** + * Handler function for message frame events during execution. + */ +export type FrameHandler = (event: FrameEvent) => void; + +/** + * Handlers to call while executing. + */ +export interface TraceOptions { + /** Called before each instruction */ + step?: TraceHandler; + /** Called when a message frame starts or ends */ + frame?: FrameHandler; +} + +/** + * A message frame of a recorded trace. + */ +export interface TraceFrame extends MessageFrame { + /** Index of the enclosing frame in `Trace.frames` */ + parent?: number; + /** Index of the frame's first step */ + first: number; + /** Index of the first step after the frame ended */ + end: number; + returnData: Uint8Array; + reverted: boolean; +} + +/** + * One transaction's steps and frames, with the state at every step. + * Pass it to `Executor.call`, `deploy` or `execute`; a trace records + * one transaction. + */ +export interface Trace { + readonly steps: readonly TraceStep[]; + readonly frames: readonly TraceFrame[]; + /** The frame a step runs in */ + frameAt(index: number): TraceFrame; + /** + * The state at a step. Storage the transaction did not touch is + * read from the executor, and only while it has not changed since + * the transaction: afterwards such a read throws. + */ + stateAt(index: number): S; + /** @internal */ + readonly [recorder]: Recorder; +} + +/** + * Options for recording a trace. + */ +export interface RecordOptions extends TraceOptions { + /** How to record memory (default "full") */ + memory?: MemoryPolicy; +} + +/** @internal */ +export const recorder = Symbol("recorder"); + +/** + * What the executor tells a trace while it runs a transaction. + * @internal + */ +export interface Recorder { + readonly started: boolean; + enter(frame: MessageFrame, code: Uint8Array): void; + exit(event: FrameEvent & { kind: "exit" }): void; + /** The value in a storage slot, just before SLOAD or SSTORE */ + observe(address: string, slot: bigint, value: bigint): void; + step(event: TraceStep, stack: bigint[], memory: Uint8Array): void; + /** The transaction ended; read untouched storage with `base` */ + end(base: (address: string, slot: bigint) => Promise): void; +} + +/** + * Record a trace with the state at every step. + */ +export function createTrace( + options: RecordOptions & { memory: "none" }, +): Trace; +export function createTrace( + options?: RecordOptions & { memory?: "full" | "changed" }, +): Trace; +export function createTrace( + options: RecordOptions, +): Trace; +export function createTrace( + options: RecordOptions = {}, +): Trace { + const policy = options.memory ?? "full"; + const steps: TraceStep[] = []; + const frames: (TraceFrame & { code: Uint8Array })[] = []; + const stepFrame: number[] = []; + const stacks: bigint[][] = []; + const memories: Uint8Array[] = []; + const returndatas: Uint8Array[] = []; + + // open frames, innermost last, with what each needs while open + const open: { + frame: number; + returndata: Uint8Array; + storageWrites: number; + transientWrites: number; + memory?: Uint8Array; + opcode?: number; + }[] = []; + + const storage = createJournal(); + const transient = createJournal(); + const observed = new Map(); + let base: ((address: string, slot: bigint) => Promise) | undefined; + let started = false; + + const record: Recorder = { + get started() { + return started; + }, + + enter(message, code) { + started = true; + const parent = open[open.length - 1]?.frame; + frames.push({ + ...message, + ...(parent === undefined ? {} : { parent }), + first: steps.length, + end: steps.length, + returnData: new Uint8Array(), + reverted: false, + code, + }); + open.push({ + frame: frames.length - 1, + returndata: new Uint8Array(), + storageWrites: storage.writes.length, + transientWrites: transient.writes.length, + }); + options.frame?.({ kind: "enter", frame: message, first: steps.length }); + }, - const handler: TraceHandler = (step) => { - steps.push(step); + exit(event) { + const closed = open.pop()!; + const frame = frames[closed.frame]; + frame.end = steps.length; + frame.returnData = event.returnData; + frame.reverted = event.reverted; + if (event.reverted) { + storage.undo(closed.storageWrites, steps.length); + transient.undo(closed.transientWrites, steps.length); + } + const parent = open[open.length - 1]; + if (parent) { + // after a successful create, RETURNDATASIZE is zero + parent.returndata = + frame.create && !event.reverted ? new Uint8Array() : event.returnData; + } + options.frame?.(event); + }, + + observe(address, slot, value) { + const key = keyOf(address, slot); + if (!observed.has(key)) { + observed.set(key, value); + } + }, + + step(event, stack, memory) { + const { op: opcode } = event; + const index = steps.length; + const current = open[open.length - 1]!; + steps.push(event); + stepFrame.push(current.frame); + stacks.push([...stack]); + returndatas.push(current.returndata); + + if (policy === "full") { + memories.push(new Uint8Array(memory)); + } else if (policy === "changed") { + const shared = shareMemory(current.memory, current.opcode, memory); + current.memory = shared; + current.opcode = opcode; + memories.push(shared); + } + + // a call or create clears the return data, even when it fails + // before its message starts (then no exit sets it) + if (callOpcodes.has(opcode)) { + current.returndata = new Uint8Array(); + } + + if (opcode === 0x55 /* SSTORE */ || opcode === 0x5d /* TSTORE */) { + const [value, slot] = stack.slice(-2); + (opcode === 0x55 ? storage : transient).write( + keyOf(event.address, slot), + value, + index, + ); + } + + options.step?.(event, index); + }, + + end(read) { + base = read; + }, }; - const getTrace = (): Trace => ({ steps: [...steps] }); + const frameAt = (index: number) => { + check(index); + return frames[stepFrame[index]]; + }; - return [handler, getTrace]; + const check = (index: number) => { + if (!Number.isInteger(index) || index < 0 || index >= steps.length) { + throw new RangeError(`no trace step ${index}`); + } + }; + + const stateAt = (index: number): StepState | StepStateWithoutMemory => { + const frame = frameAt(index); + const { pc, opcode } = steps[index]; + const state: StepStateWithoutMemory = { + at: { traceIndex: index, pc, opcode }, + stack: stacks[index], + async storage(slot) { + const key = keyOf(frame.address, slot); + const written = storage.valueAt(key, index); + if (written !== undefined) return written; + const value = observed.get(key); + if (value !== undefined) return value; + if (!base) { + throw new Error("the trace's transaction has not ended"); + } + return base(frame.address, slot); + }, + async transient(slot) { + return transient.valueAt(keyOf(frame.address, slot), index) ?? 0n; + }, + calldata: frame.calldata, + returndata: returndatas[index], + code: frame.code, + }; + return policy === "none" ? state : { ...state, memory: memories[index] }; + }; + + return { + steps, + frames, + frameAt, + stateAt, + [recorder]: record, + } as Trace; +} + +interface Write { + value: bigint; + /** The writing step; the value holds from the next step */ + step: number; + /** The first step at which the write is undone */ + undone: number; +} + +/** + * Writes to storage or transient storage, by address and slot. + */ +function createJournal() { + const writes: Write[] = []; + const byKey = new Map(); + return { + writes, + write(key: string, value: bigint, step: number) { + const write = { value, step, undone: Infinity }; + writes.push(write); + const list = byKey.get(key); + if (list) list.push(write); + else byKey.set(key, [write]); + }, + /** Undo the writes from `start` on, from step `at` */ + undo(start: number, at: number) { + for (let i = start; i < writes.length; i++) { + writes[i].undone = Math.min(writes[i].undone, at); + } + }, + /** The value in effect at a step, if a write set it */ + valueAt(key: string, index: number): bigint | undefined { + const list = byKey.get(key) ?? []; + for (let i = list.length - 1; i >= 0; i--) { + const { value, step, undone } = list[i]; + if (step < index && undone > index) return value; + } + return undefined; + }, + }; +} + +function keyOf(address: string, slot: bigint): string { + return `${address}:${slot.toString(16)}`; +} + +/** CREATE, CALL, CALLCODE, DELEGATECALL, CREATE2, STATICCALL */ +const callOpcodes = new Set([0xf0, 0xf1, 0xf2, 0xf4, 0xf5, 0xfa]); + +/** + * Opcodes after which a frame's memory may differ: they write memory + * or expand it. + */ +const memoryOpcodes = new Set([ + 0x20, // KECCAK256 + 0x37, // CALLDATACOPY + 0x39, // CODECOPY + 0x3c, // EXTCODECOPY + 0x3e, // RETURNDATACOPY + 0x51, // MLOAD + 0x52, // MSTORE + 0x53, // MSTORE8 + 0x5e, // MCOPY + 0xa0, // LOG0..LOG4 + 0xa1, + 0xa2, + 0xa3, + 0xa4, + 0xf0, // CREATE + 0xf1, // CALL + 0xf2, // CALLCODE + 0xf4, // DELEGATECALL + 0xf5, // CREATE2 + 0xfa, // STATICCALL +]); + +/** + * The frame's previous memory copy when memory has not changed since + * then; otherwise a new copy. + */ +function shareMemory( + previous: Uint8Array | undefined, + previousOpcode: number | undefined, + current: Uint8Array, +): Uint8Array { + if ( + previous && + previous.length === current.length && + (!memoryOpcodes.has(previousOpcode!) || equalBytes(previous, current)) + ) { + return previous; + } + return new Uint8Array(current); +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; } /** @@ -78,17 +501,10 @@ async function* traceExecution( executor: Executor, options: ExecutionOptions, ): AsyncGenerator { - const steps: TraceStep[] = []; - const handler: TraceHandler = (step) => { - steps.push(step); - }; - - await executor.execute(options, handler); + const trace = createTrace(); + await executor.execute(options, trace); - for (let i = 0; i < steps.length; i++) { - yield createMachineState(executor, { - traceStep: steps[i], - traceIndex: BigInt(i), - }); + for (let i = 0; i < trace.steps.length; i++) { + yield createMachineState(trace.stateAt(i)); } } diff --git a/packages/evm/test/bytecode.ts b/packages/evm/test/bytecode.ts new file mode 100644 index 0000000000..0e699a8400 --- /dev/null +++ b/packages/evm/test/bytecode.ts @@ -0,0 +1,106 @@ +/** + * A small assembler for test bytecode. + * + * `asm` takes whitespace-separated mnemonics. `PUSHn` takes the next + * token as its value (decimal or 0x hex). `name:` is a JUMPDEST named + * `name`; `@name` pushes its offset (as PUSH2). `;` starts a comment. + */ + +const opcodes: Record = { + STOP: 0x00, + ADD: 0x01, + SUB: 0x03, + LT: 0x10, + GT: 0x11, + EQ: 0x14, + ISZERO: 0x15, + KECCAK256: 0x20, + ADDRESS: 0x30, + CALLDATALOAD: 0x35, + CALLDATASIZE: 0x36, + CALLDATACOPY: 0x37, + CODECOPY: 0x39, + EXTCODECOPY: 0x3c, + RETURNDATASIZE: 0x3d, + RETURNDATACOPY: 0x3e, + BLOCKHASH: 0x40, + COINBASE: 0x41, + TIMESTAMP: 0x42, + NUMBER: 0x43, + PREVRANDAO: 0x44, + CHAINID: 0x46, + BASEFEE: 0x48, + BLOBBASEFEE: 0x4a, + POP: 0x50, + MLOAD: 0x51, + MSTORE: 0x52, + MSTORE8: 0x53, + SLOAD: 0x54, + SSTORE: 0x55, + JUMP: 0x56, + JUMPI: 0x57, + GAS: 0x5a, + JUMPDEST: 0x5b, + TLOAD: 0x5c, + TSTORE: 0x5d, + MCOPY: 0x5e, + CREATE: 0xf0, + CALL: 0xf1, + RETURN: 0xf3, + DELEGATECALL: 0xf4, + CREATE2: 0xf5, + REVERT: 0xfd, + INVALID: 0xfe, +}; +for (let n = 1; n <= 16; n++) { + opcodes[`DUP${n}`] = 0x7f + n; + opcodes[`SWAP${n}`] = 0x8f + n; +} + +/** Assemble `source` into hex (no 0x) */ +export function asm(source: string): string { + const tokens = source + .replace(/;[^\n]*/g, "") + .split(/\s+/) + .filter((token) => token !== ""); + + const labels = new Map(); + const assemble = (): string => { + let hex = ""; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const push = /^PUSH(\d+)$/.exec(token); + if (token.endsWith(":")) { + labels.set(token.slice(0, -1), hex.length / 2); + hex += "5b"; + } else if (token.startsWith("@")) { + hex += "61" + word(BigInt(labels.get(token.slice(1)) ?? 0), 2); + } else if (push) { + const size = Number(push[1]); + hex += (0x5f + size).toString(16) + word(BigInt(tokens[++i]), size); + } else if (token in opcodes) { + hex += opcodes[token].toString(16).padStart(2, "0"); + } else { + throw new Error(`unknown token ${token}`); + } + } + return hex; + }; + assemble(); // find the labels + return assemble(); +} + +/** Creation code that deploys `runtime` (hex, no 0x) */ +export function creation(runtime: string): string { + return ( + asm(`PUSH2 ${runtime.length / 2} DUP1 PUSH1 12 PUSH1 0 CODECOPY + PUSH1 0 RETURN`) + runtime + ); +} + +/** `value` as `size` bytes of hex */ +export function word(value: bigint, size = 32): string { + return BigInt.asUintN(size * 8, value) + .toString(16) + .padStart(size * 2, "0"); +} diff --git a/packages/evm/test/contracts.ts b/packages/evm/test/contracts.ts new file mode 100644 index 0000000000..5fe6364c2a --- /dev/null +++ b/packages/evm/test/contracts.ts @@ -0,0 +1,60 @@ +/** + * Runtime code of contracts for tests (hex, no 0x) + */ +import { Executor } from "#executor"; +import { asm, creation } from "./bytecode.js"; + +export const alice = "0x" + "aa".repeat(20); +export const bob = "0x" + "bb".repeat(20); +export const carol = "0x" + "cc".repeat(20); + +/** An executor with alice, bob and carol funded */ +export async function funded(executor = new Executor()): Promise { + for (const account of [alice, bob, carol]) { + await executor.fund(account, 10n ** 18n); + } + return executor; +} + +/** Deploy runtime code from alice; its address */ +export async function deployed( + executor: Executor, + runtime: string, +): Promise { + const { address } = await executor.deploy({ + from: alice, + create: creation(runtime), + }); + return address!; +} + +/** Stores calldata word 0 at slot 0 and returns it */ +export const store = asm(` + PUSH1 0 CALLDATALOAD DUP1 PUSH1 0 SSTORE + PUSH1 0 MSTORE PUSH1 32 PUSH1 0 RETURN +`); + +/** Stores calldata word 0 at slot 0, calls `to` with that word and + * returns what `to` returns */ +export const forward = (to: string) => + asm(` + PUSH1 0 CALLDATALOAD DUP1 PUSH1 0 SSTORE PUSH1 0 MSTORE + PUSH1 32 PUSH1 32 PUSH1 32 PUSH1 0 PUSH1 0 PUSH20 ${to} GAS CALL POP + PUSH1 32 PUSH1 32 RETURN +`); + +/** + * Given n (calldata word 0): stores 0x10 + n at slot n; for n < 2, + * calls itself with n + 1; for n = 1, then reverts with the word n + 1. + * So a call with 0 runs three frames, and the middle one reverts. + */ +export const recurse = asm(` + PUSH1 0 CALLDATALOAD + DUP1 PUSH1 16 ADD DUP2 SSTORE + PUSH1 2 DUP2 LT ISZERO @done JUMPI + DUP1 PUSH1 1 ADD PUSH1 0 MSTORE + PUSH1 0 PUSH1 0 PUSH1 32 PUSH1 0 PUSH1 0 ADDRESS GAS CALL POP + DUP1 PUSH1 1 EQ ISZERO @done JUMPI + PUSH1 32 PUSH1 0 REVERT + done: STOP +`); diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index cf18e995a2..402f21e403 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -23,7 +23,11 @@ import { type SourceRange, extractSourceRange, } from "@ethdebug/bugc-react"; -import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; +import { + Executor, + createTrace, + type TraceStep as EvmStep, +} from "@ethdebug/evm"; import { dereference, Data, type Machine } from "@ethdebug/pointers"; import { storageByStep } from "./storageByStep"; import { @@ -39,6 +43,9 @@ import type { Program } from "@ethdebug/format"; import { Drawer } from "@theme/Drawer"; import { useTracePlayground } from "./TracePlaygroundContext"; +/** A trace step with the stack and memory before it */ +type TraceStep = EvmStep & { stack: readonly bigint[]; memory: Uint8Array }; + import "./TraceDrawer.css"; // Bounds for the draggable instruction-object footer (in px). @@ -507,11 +514,15 @@ function TraceDrawerContent(): JSX.Element { } } - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); + const recorded = createTrace(); + await executor.execute({}, recorded); - const collectedTrace = getTrace(); - setTrace(collectedTrace.steps); + setTrace( + recorded.steps.map((step, i) => { + const { stack, memory } = recorded.stateAt(i); + return { ...step, stack, memory }; + }), + ); setCurrentStep(0); setInitialStorage(storageEntries); } catch (e) { @@ -1012,7 +1023,7 @@ function OpcodeList({ } interface StackDisplayProps { - stack: bigint[]; + stack: readonly bigint[]; } function StackDisplay({ stack }: StackDisplayProps): JSX.Element { @@ -1329,7 +1340,7 @@ function traceStepToState( Data.fromUint(v).padUntilAtLeast(32), ); - const memoryData = step.memory ? Data.fromBytes(step.memory) : Data.zero(); + const memoryData = Data.fromBytes(step.memory); const storageMap = new Map(); for (const [slot, value] of Object.entries(storage)) { diff --git a/packages/web/src/theme/ProgramExample/storageByStep.ts b/packages/web/src/theme/ProgramExample/storageByStep.ts index 3dc3413257..46dab73943 100644 --- a/packages/web/src/theme/ProgramExample/storageByStep.ts +++ b/packages/web/src/theme/ProgramExample/storageByStep.ts @@ -1,5 +1,3 @@ -import type { TraceStep } from "@ethdebug/evm"; - /** * Compute the storage that holds at each step of a trace: the * initial storage plus the SSTOREs executed before that step. @@ -10,7 +8,7 @@ import type { TraceStep } from "@ethdebug/evm"; * its operands on the stack (key on top, then value). */ export function storageByStep( - trace: TraceStep[], + trace: { opcode: string; stack: readonly bigint[] }[], initial: Record, ): Record[] { const states: Record[] = [];