Simulating a blockchain
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.State
s. 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;
}