Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/evm2-inspection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"ox": minor
---

Added `Evm.setInspector` and `Evm.clearInspector`, which record what an execution did and report it as a trace on the result.

```ts
import { Evm, Inspector } from 'ox/evm'

Evm.setInspector(evm, {})

const result = Evm.callTx(evm, transaction)
const [root] = Inspector.tree(result.trace)
root.calls.length
```
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,11 @@
"types": "./dist/evm/ExecutedTx.d.ts",
"default": "./dist/evm/ExecutedTx.js"
},
"./evm/Inspector": {
"src": "./src/evm/Inspector.ts",
"types": "./dist/evm/Inspector.d.ts",
"default": "./dist/evm/Inspector.js"
},
"./evm/PendingState": {
"src": "./src/evm/PendingState.ts",
"types": "./dist/evm/PendingState.d.ts",
Expand Down
93 changes: 93 additions & 0 deletions src/evm/Evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Errors from '../core/Errors.js'
import * as TxEnvelope from '../core/TxEnvelope.js'
import * as Database from './Database.js'
import * as ExecutedTx from './ExecutedTx.js'
import type * as Inspector from './Inspector.js'
import type * as Ethereum from './Ethereum.js'
import * as SpecId from './SpecId.js'
import * as TxResult from './TxResult.js'
Expand Down Expand Up @@ -765,6 +766,98 @@ function snapshot(version: Version | undefined): Version | undefined {
}
}

/**
* Installs an inspector, so executions record what they did.
*
* A trace comes back on the result of each execution afterwards. Recording
* cannot change what executes: the same transaction produces the same result
* traced or not.
*
* @example
* ```ts twoslash
* // @noErrors
* import { Evm, Inspector } from 'ox/evm'
*
* // Calls, creates, logs, and self-destructs. Cheap enough to leave on.
* Evm.setInspector(evm, {})
*
* const result = Evm.callTx(evm, transaction)
* Inspector.tree(result.trace)
* ```
*
* @example
* ### Recording instructions
*
* ```ts twoslash
* // @noErrors
* import { Evm, Inspector } from 'ox/evm'
*
* // Millions of events for a busy transaction, so bound it and expect
* // `truncated`.
* Evm.setInspector(evm, {
* limit: 4_000_000,
* stack: true,
* steps: true
* })
*
* const result = Evm.callTx(evm, transaction)
* Inspector.steps(result.trace)
* ```
*
* @param evm - EVM to inspect.
* @param options - What to record.
*/
export function setInspector<asynchronous extends boolean>(
evm: Evm<asynchronous>,
options: Inspector.Options = {},
): Awaitable<asynchronous, void> {
// Queued like the other setters: an asynchronous execution can be parked
// mid-retry, and changing the recording under it would trace one execution
// with two sets of settings.
return attempt(evm, () =>
evm['~engine'].setInspector({
enabled: true,
limit: options.limit ?? 1_048_576,
memory: options.memory ?? false,
stack: options.stack ?? false,
steps: options.steps ?? false,
}),
)
}

export declare namespace setInspector {
type ErrorType = AbiError | BorrowedError | Errors.GlobalErrorType
}

/**
* Removes the inspector.
*
* The engine then holds none, which is what makes an untraced execution free:
* an inspector that is present costs work on every instruction whatever it
* records.
*
* @example
* ```ts twoslash
* // @noErrors
* import { Evm } from 'ox/evm'
*
* Evm.clearInspector(evm)
* ```
*
* @param evm - EVM to stop inspecting.
*/
export function clearInspector<asynchronous extends boolean>(
evm: Evm<asynchronous>,
): Awaitable<asynchronous, void> {
return attempt(evm, () =>
evm['~engine'].setInspector({ enabled: false, limit: 0 }),
)
}

export declare namespace clearInspector {
type ErrorType = setInspector.ErrorType
}

// Merges block values over the current ones, field by field rather than with a
// spread: an omitted field is `undefined` in the partial, and spreading it would
// erase the value it should keep.
Expand Down
207 changes: 207 additions & 0 deletions src/evm/Inspector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type * as Address from '../core/Address.js'
import type * as Hex from '../core/Hex.js'
import type * as codec from './internal/codec.js'

/**
* A recorded execution.
*
* Events keep the order the engine produced them in, one per hook it called.
* Nothing is interpreted: {@link ox#Inspector.(tree:function)} builds the call
* tree from this, so the shape can change without a new engine.
*/
export type Trace = codec.Trace

/** One recorded hook call. */
export type Event = codec.TraceEvent

/** What an inspector records. */
export type Options = {
/**
* Largest trace to keep, in bytes.
*
* Recording stops at the limit and the trace reports `truncated`, keeping what
* ran first: a trace is always a prefix of the execution, never a stream with
* gaps. Execution is unaffected either way.
*
* @default 1_048_576
*/
limit?: number | undefined
/** Records memory size on each instruction. Requires `steps`. */
memory?: boolean | undefined
/** Records the stack on each instruction. Requires `steps`. */
stack?: boolean | undefined
/**
* Records every instruction.
*
* Off by default, and worth leaving off: a mainnet transaction runs millions of
* instructions, where calls, creates, and logs number in the tens. Turn this on
* to debug a specific execution, not to observe one in production.
*
* @default false
*/
steps?: boolean | undefined
}

/**
* A call or create in the tree, with whatever it did inside it.
*
* The shape {@link ox#Inspector.(tree:function)} produces.
*/
export type Frame = {
/** Account that made the call. */
caller: Address.Address
/** Calls and creates this frame made, in order. */
calls: readonly Frame[]
/** Account whose code ran. */
codeAddress: Address.Address
/** Address a create deployed, when it succeeded. */
createdAddress?: Address.Address | undefined
/** How deep this frame sits. */
depth: number
/** Account the call was addressed to. */
destination: Address.Address
/** Gas the frame was given. */
gasLimit: bigint
/** Gas the frame consumed. Absent when the trace ended before it returned. */
gasSpent?: bigint | undefined
/** Calldata, or initcode for a create. */
input: Hex.Hex
/**
* Which instruction produced this frame.
*
* `'unknown'` for a kind a later evm2 revision added, which this artifact
* reports rather than folding into `'call'`.
*/
kind: (typeof codec.messageKinds)[number] | typeof codec.unknownMessageKind
/** Logs this frame emitted directly. */
logs: readonly {
address: Address.Address
data: Hex.Hex
topics: readonly Hex.Hex[]
}[]
/** Returned data, or revert data. Absent when the frame never returned. */
output?: Hex.Hex | undefined
/** Accounts this frame self-destructed, with where their balance went. */
selfdestructs: readonly {
contract: Address.Address
target: Address.Address
value: bigint
}[]
/** Why the frame stopped, as evm2's discriminant. Absent when unfinished. */
stop?: number | undefined
/** Value transferred. */
value: bigint
}

/**
* Builds the call tree a trace describes.
*
* The engine records a flat sequence; nesting is recovered here from the order of
* the call and return events. A truncated trace yields the frames it captured,
* with the unfinished ones missing their output and gas.
*
* @example
* ```ts twoslash
* // @noErrors
* import { Evm, Inspector } from 'ox/evm'
*
* Evm.setInspector(evm, {})
* const result = Evm.callTx(evm, transaction)
*
* const [root] = Inspector.tree(result.trace)
* root.calls.length
* ```
*
* @param trace - Recorded execution.
* @returns The frames the trace describes, outermost first.
*/
export function tree(trace: Trace | undefined): readonly Frame[] {
if (!trace) return []

const roots: Frame[] = []
// Frames still open, innermost last. A return event closes the last one.
const open: Frame[] = []

const current = () => open[open.length - 1]

for (const event of trace.events) {
if (event.kind === 'call' || event.kind === 'create') {
const frame: Frame = {
caller: event.caller,
calls: [],
codeAddress: event.codeAddress,
depth: event.depth,
destination: event.destination,
gasLimit: event.gasLimit,
input: event.input,
kind: event.messageKind,
logs: [],
selfdestructs: [],
value: event.value,
}
const parent = current()
if (parent) (parent.calls as Frame[]).push(frame)
else roots.push(frame)
open.push(frame)
continue
}

if (event.kind === 'callEnd' || event.kind === 'createEnd') {
const frame = open.pop()
if (!frame) continue
Object.assign(frame, {
gasSpent: event.gasSpent,
output: event.output,
stop: event.stop,
...(event.createdAddress
? { createdAddress: event.createdAddress }
: {}),
})
continue
}

// Logs and self-destructs belong to whichever frame is running.
const frame = current()
if (!frame) continue
if (event.kind === 'log')
(frame.logs as Frame['logs'][number][]).push({
address: event.address,
data: event.data,
topics: event.topics,
})
else if (event.kind === 'selfdestruct')
(frame.selfdestructs as Frame['selfdestructs'][number][]).push({
contract: event.contract,
target: event.target,
value: event.value,
Comment on lines +173 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the self-destructed contract in frame trees

For any SELFDESTRUCT, the raw event includes both contract (the account being destroyed) and target (the beneficiary), but the tree view drops contract and exposes only the beneficiary/value. Consumers using Inspector.tree therefore cannot tell which account was removed, and guessing from the frame is wrong for contexts such as delegated execution; include event.contract in the frame's selfdestruct entry.

Useful? React with 👍 / 👎.

})
}

return roots
}

/**
* Returns the instructions a trace recorded, in order.
*
* Empty unless the inspector recorded steps.
*
* @example
* ```ts twoslash
* // @noErrors
* import { Inspector } from 'ox/evm'
*
* const steps = Inspector.steps(result.trace)
* steps[0]?.opcode
* ```
*
* @param trace - Recorded execution.
* @returns Each instruction, with its program counter, gas, and stack.
*/
export function steps(
trace: Trace | undefined,
): readonly Extract<Event, { kind: 'step' }>[] {
if (!trace) return []
return trace.events.filter(
(event): event is Extract<Event, { kind: 'step' }> => event.kind === 'step',
)
}
7 changes: 7 additions & 0 deletions src/evm/TxResult.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as Address from '../core/Address.js'
import type * as Hex from '../core/Hex.js'
import type * as Inspector from './Inspector.js'
import type * as PendingState from './PendingState.js'

/**
Expand Down Expand Up @@ -92,6 +93,12 @@ export type TxResult = {
stop: Stop
/** Total gas spent, regular plus state, before any refund. */
totalGasSpent: bigint
/**
* What the execution did, when an inspector was recording.
*
* Absent unless {@link ox#Evm.(setInspector:function)} installed one.
*/
trace?: Inspector.Trace | undefined
}

/**
Expand Down
Loading
Loading