Skip to main content

Simulating a blockchain

warning

In case you missed the note on this section's first page, the functionality described in this page uses the unmaintained Ganache software library for simulating the EVM. See note for rationale and risk expectations.

This reference implemention relies heavily on the Machine interface it defines for reading the state of a running EVM; this page describes how this implementation's integration tests adapt an EIP-1193 JavaScript provider object to this interface.

Since the primary purpose of Machine is to represent a series of code execution steps, the adapter described here simplifies the concept of an execution trace by restricting it to mean that which happens within the course of an Ethereum transaction. The tests thus define a machineForProvider function to adapt a provider object for a particular transaction hash.

As a result, this code only functions in the context of a provider to a blockchain whose JSON-RPC exposes the original go-ethereum's "debug_traceTransaction" method, which exposes the state of the EVM at each step of code execution for a particular transaction. Other kinds of traces (such as tracing the execution of an "eth_call" request) are left to remain intentionally out-of-scope for the purposes of testing this implementation. Other implementations of the Machine interface need not make this restriction.

Implementing machineForProvider()

The machineForProvider() function takes two arguments and returns an object adhering to the Machine interface. See the code listing for this function:

export function machineForProvider(
provider: EthereumProvider,
transactionHash: Data
): Machine {
return {
trace(): AsyncIterable<Machine.State> {
return {
async *[Symbol.asyncIterator]() {
const structLogs = await requestStructLogs(
`0x${transactionHash.asUint().toString(16)}`,
provider
);

for (const [index, structLog] of structLogs.entries()) {
yield toMachineState(structLog, index);
}
}
};
}
};
}

This function is written to return an object whose trace() method matches that which is defined by Machine: a method to asynchronously produce an iterable list of Machine.States. This function leverages two other helper functions as part of the behavior of this method: requestStructLogs() and toMachineState().

Requesting "struct logs"

The Geth-style "debug_traceTransaction" method returns a list of execution steps and machine states inside the "structLogs" field of the response's result object.

The asynchronous requestStructLogs function is implemented as follows:

async function requestStructLogs(
transactionHash: string,
provider: EthereumProvider
) {
const { structLogs } = await provider.request({
method: "debug_traceTransaction",
params: [transactionHash]
});

return structLogs;
}

Since Ganache does not have a publicly-documented or easily-accessible exported collection of types, but since it does use string literal types to infer the specific type of provider request being made, this code can use TypeScript's type interference to ensure type safety in the adapter:

type StructLogs = Depromise<ReturnType<typeof requestStructLogs>>;
type StructLog = Dearray<StructLogs>;
type Depromise<P> = P extends Promise<infer T> ? T : P;
type Dearray<A> = A extends Array<infer T> ? T : A;

These types are not exported by this module because they are internal to machineForProvider() concerns.

Converting to Machine.State

The toMachineState() function is implemented by leveraging the use of the addressing schemes defined by the ethdebug/format/pointer schema. Notice the use of the various helper functions, listed below.

function toMachineState(step: StructLog, index: number): Machine.State {
return {
traceIndex: constantUint(index),
programCounter: constantUint(step.pc),
opcode: Promise.resolve(step.op),

stack: makeStack(step.stack),

memory: makeBytes(step.memory),

storage: makeWords(step.storage),

calldata: undefined as unknown as Machine.State.Bytes,
returndata: undefined as unknown as Machine.State.Bytes,
code: undefined as unknown as Machine.State.Bytes,

transient: undefined as unknown as Machine.State.Words,
};
}

Helper function: constantUint()

Since the interface defined by Machine.State is more asynchronous than likely necessary (certainly it is more asynchronous than necessary for these testing purposes), many properties defined within Machine.State must be converted from a readily-available constant value into a Promise that resolves to that value:

function constantUint(value: number): Promise<bigint> {
return Promise.resolve(Data.fromNumber(value).asUint());
}

Helper function: makeStack()

Although the specification defines the "stack" data location to use a regular segment-based addressing scheme, this reference implementation distinguishes the stack from the other segment-based locations because of the use of numeric, unstable slot values.

function makeStack(stack: StructLog["stack"]): Machine.State.Stack {
const length = stack.length;

return {
length: constantUint(length),

async peek({
depth,
slice: {
offset = 0n,
length = 32n
} = {}
}) {
const entry = stack.at(-Number(depth));
const data = Data.fromHex(`0x${entry || ""}`);

const sliced = new Uint8Array(data).slice(
Number(offset),
Number(offset + length)
);

return new Data(sliced);
}
};
}

Helper function: makeWords()

For other segment-based locations, the makeWords() function is used:

function makeWords(slots: StructLog["storage"]): Machine.State.Words {
return {
async read({
slot,
slice: {
offset = 0n,
length = 32n
} = {}
}) {
const rawHex = slots[
slot.resizeTo(32).toHex().slice(2) as keyof typeof slots
];

const data = Data.fromHex(`0x${rawHex}`);

return new Data(data.slice(
Number(offset),
Number(offset + length)
));
}
};
}

Helper function: makeBytes()

The makeBytes() function is used for plain bytes-based data locations, such as "memory":

function makeBytes(words: StructLog["memory"]): Machine.State.Bytes {
const data = Data.fromHex(`0x${words.join("")}`);

return {
length: constantUint(data.length),

async read({ slice: { offset, length } }) {
return new Data(data.slice(
Number(offset),
Number(offset + length)
));
}
}
}

Note on loading Ganache

To prevent Ganache's warnings from appearing in test console output, a custom loadGanache() function is defined to suppress known warnings while importing the module:

export async function loadGanache() {
const originalWarn = console.warn;
console.warn = (...args: any[]) => {
if (
args.length > 0 &&
typeof args[0] === "string" &&
args[0].includes("bigint: Failed to load bindings")
) {
return;
}

originalWarn(...args);
};

const { default: Ganache } = await import("ganache");

console.warn = originalWarn;
return Ganache;
}