Skip to content
This repository has been archived by the owner on Feb 26, 2024. It is now read-only.

Commit

Permalink
fix: add debug_traceTransaction RPC method (#614)
Browse files Browse the repository at this point in the history
Co-authored-by: David Murdoch <[email protected]>
  • Loading branch information
tcoulter and davidmurdoch committed Jan 19, 2021
1 parent 7157591 commit 6569f70
Show file tree
Hide file tree
Showing 9 changed files with 1,123 additions and 2 deletions.
30 changes: 29 additions & 1 deletion src/chains/ethereum/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { TypedData as NotTypedData, signTypedData_v4 } from "eth-sig-util";
import { EthereumInternalOptions } from "./options";
import { types, Data, Quantity } from "@ganache/utils";
import Blockchain from "./blockchain";
import Blockchain, { TransactionTraceOptions } from "./blockchain";
import Tag from "./things/tags";
import { VM_EXCEPTION, VM_EXCEPTIONS } from "./errors/errors";
import Address from "./things/address";
Expand Down Expand Up @@ -1777,6 +1777,34 @@ export default class EthereumApi implements types.Api {
}
//#endregion

//#region debug

/**
* Attempt to run the transaction in the exact same manner as it was executed
* on the network. It will replay any transaction that may have been executed
* prior to this one before it will finally attempt to execute the transaction
* that corresponds to the given hash.
*
* In addition to the hash of the transaction you may give it a secondary
* optional argument, which specifies the options for this specific call.
* The possible options are:
*
* * `disableStorage`: {boolean} Setting this to `true` will disable storage capture (default = `false`).
* * `disableMemory`: {boolean} Setting this to `true` will disable memory capture (default = `false`).
* * `disableStack`: {boolean} Setting this to `true` will disable stack capture (default = `false`).
*
* @param transactionHash
* @param options
*/
async debug_traceTransaction(
transactionHash: string,
options?: TransactionTraceOptions
) {
return this.#blockchain.traceTransaction(transactionHash, options);
}

//#endregion

//#region personal
/**
* Returns all the Ethereum account addresses of all keys that have been
Expand Down
327 changes: 327 additions & 0 deletions src/chains/ethereum/src/blockchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import { VmError, ERROR } from "ethereumjs-vm/dist/exceptions";
import { EthereumInternalOptions } from "./options";
import { Snapshots } from "./types/snapshots";
import { RuntimeBlock, Block } from "./things/runtime-block";
import { ITraceData, TraceDataFactory } from "./things/trace-data";
import TraceStorageMap from "./things/trace-storage-map";

const {
BUFFER_EMPTY,
RPCQUANTITY_EMPTY,
Expand Down Expand Up @@ -81,6 +84,45 @@ type BlockchainTypedEvents = {
};
type BlockchainEvents = "start" | "stop";

export type TransactionTraceOptions = {
disableStorage?: boolean;
disableMemory?: boolean;
disableStack?: boolean;
};

export type StructLog = {
depth: number;
error: string;
gas: number;
gasCost: number;
memory: Array<ITraceData>;
op: string;
pc: number;
stack: Array<ITraceData>;
storage: TraceStorageMap;
};

interface Logger {
log(message?: any, ...optionalParams: any[]): void;
}

export type BlockchainOptions = {
db?: string | object;
db_path?: string;
initialAccounts?: Account[];
hardfork?: string;
allowUnlimitedContractSize?: boolean;
gasLimit?: Quantity;
time?: Date;
blockTime?: number;
coinbase: Account;
chainId: number;
common: Common;
legacyInstamine: boolean;
vmErrorsOnRPCResponse: boolean;
logger: Logger;
};

/**
* Sets the provided VM state manager's state root *without* first
* checking for checkpoints or flushing the existing cache.
Expand Down Expand Up @@ -795,6 +837,291 @@ export default class Blockchain extends Emittery.Typed<
}
}

/**
* traceTransaction
*
* Run a previously-run transaction in the same state in which it occurred at the time it was run.
* This will return the vm-level trace output for debugging purposes.
*
* Strategy:
*
* 1. Find block where transaction occurred
* 2. Set state root of that block
* 3. Rerun every transaction in that block prior to and including the requested transaction
* 4. Send trace results back.
*
* @param transactionHash
* @param params
*/
public async traceTransaction(
transactionHash: string,
params: TransactionTraceOptions
) {
let currentDepth = -1;
const storageStack: TraceStorageMap[] = [];

// TODO: gas could go theoretically go over Number.MAX_SAFE_INTEGER.
// (Ganache v2 didn't handle this possibility either, so it hasn't been
// updated yet)
let gas = 0;
// TODO: returnValue isn't used... it wasn't used in v2 either. What's this
// supposed to be?
let returnValue = "";
const structLogs: Array<StructLog> = [];

const transactionHashBuffer = Data.from(transactionHash).toBuffer();
// #1 - get block via transaction object
const transaction = await this.transactions.get(transactionHashBuffer);

if (!transaction) {
throw new Error("Unknown transaction " + transactionHash);
}

const targetBlock = await this.blocks.get(transaction._blockNum);
const parentBlock = await this.blocks.getByHash(
targetBlock.header.parentHash.toBuffer()
);

// #2 - Set state root of original block
//
// TODO: Forking needs the forked block number passed during this step:
// https://github.com/trufflesuite/ganache-core/blob/develop/lib/blockchain_double.js#L917
const trie = new CheckpointTrie(
this.#database.trie,
parentBlock.header.stateRoot.toBuffer()
);

// Prepare the "next" block with necessary transactions
const newBlock = new RuntimeBlock(
Quantity.from(parentBlock.header.number.toBigInt() + 1n),
parentBlock.hash(),
parentBlock.header.miner,
parentBlock.header.gasLimit.toBuffer(),
// make sure we use the same timestamp as the target block
targetBlock.header.timestamp
) as RuntimeBlock & { uncleHeaders: []; transactions: Transaction[] };
newBlock.transactions = [];
newBlock.uncleHeaders = [];

const transactions = targetBlock.getTransactions();
for (const tx of transactions) {
newBlock.transactions.push(tx);

// After including the target transaction, that's all we need to do.
if (tx.hash().equals(transactionHashBuffer)) {
break;
}
}

type StepEvent = {
gasLeft: BN;
memory: Array<number>; // Not officially sure the type. Not a buffer or uint8array
stack: Array<BN>;
depth: number;
opcode: {
name: string;
};
pc: number;
address: Buffer;
};

const TraceData = TraceDataFactory();

const stepListener = (
event: StepEvent,
next: (error?: any, cb?: any) => void
) => {
// See these docs:
// https://github.com/ethereum/go-ethereum/wiki/Management-APIs

const gasLeft = event.gasLeft.toNumber();
const totalGasUsedAfterThisStep =
Quantity.from(transaction.gasLimit).toNumber() - gasLeft;
const gasUsedPreviousStep = totalGasUsedAfterThisStep - gas;
gas += gasUsedPreviousStep;

const memory: ITraceData[] = [];
if (params.disableMemory !== true) {
// We get the memory as one large array.
// Let's cut it up into 32 byte chunks as required by the spec.
let index = 0;
while (index < event.memory.length) {
const slice = event.memory.slice(index, index + 32);
memory.push(TraceData.from(Buffer.from(slice)));
index += 32;
}
}

const stack: ITraceData[] = [];
if (params.disableStack !== true) {
for (const stackItem of event.stack) {
stack.push(TraceData.from(stackItem.toBuffer()));
}
}

const structLog: StructLog = {
depth: event.depth,
error: "",
gas: gasLeft,
gasCost: 0,
memory,
op: event.opcode.name,
pc: event.pc,
stack,
storage: null
};

// The gas difference calculated for each step is indicative of gas consumed in
// the previous step. Gas consumption in the final step will always be zero.
if (structLogs.length) {
structLogs[structLogs.length - 1].gasCost = gasUsedPreviousStep;
}

if (params.disableStorage === true) {
// Add the struct log as is - nothing more to do.
structLogs.push(structLog);
next();
} else {
const { depth: eventDepth } = event;
if (currentDepth > eventDepth) {
storageStack.pop();
} else if (currentDepth < eventDepth) {
storageStack.push(new TraceStorageMap());
}

currentDepth = eventDepth;

switch (event.opcode.name) {
case "SSTORE": {
const key = stack[stack.length - 1];
const value = stack[stack.length - 2];

// new TraceStorageMap() here creates a shallow clone, to prevent other steps from overwriting
structLog.storage = new TraceStorageMap(storageStack[eventDepth]);

// Tell vm to move on to the next instruction. See below.
structLogs.push(structLog);
next();

// assign after callback because this storage change actually takes
// effect _after_ this opcode executes
storageStack[eventDepth].set(key, value);
break;
}
case "SLOAD": {
const key = stack[stack.length - 1];
vm.stateManager.getContractStorage(
event.address,
key.toBuffer(),
(err: Error, result: Buffer) => {
if (err) {
return next(err);
}

const value = TraceData.from(result);
storageStack[eventDepth].set(key, value);

// new TraceStorageMap() here creates a shallow clone, to prevent other steps from overwriting
structLog.storage = new TraceStorageMap(
storageStack[eventDepth]
);
structLogs.push(structLog);
next();
}
);
break;
}
default:
// new TraceStorageMap() here creates a shallow clone, to prevent other steps from overwriting
structLog.storage = new TraceStorageMap(storageStack[eventDepth]);
structLogs.push(structLog);
next();
}
}
};

let txHashCurrentlyProcessing: string = null;

const beforeTxListener = (tx: Transaction) => {
txHashCurrentlyProcessing = Data.from(tx.hash()).toString();
if (txHashCurrentlyProcessing == transactionHash) {
vm.on("step", stepListener);
}
};

const afterTxListener = () => {
if (txHashCurrentlyProcessing == transactionHash) {
removeListeners();
}
};

const removeListeners = () => {
vm.removeListener("step", stepListener);
vm.removeListener("beforeTx", beforeTxListener);
vm.removeListener("afterTx", afterTxListener);
};

const blocks = this.blocks;

// ethereumjs vm doesn't use the callback style anymore
const getBlock = class T {
static async [promisify.custom](number: BN) {
const block = await blocks.get(number.toBuffer()).catch(_ => null);
return block ? block.value : null;
}
};

const vm = new VM({
state: trie,
activatePrecompiles: true,
common: this.#common,
allowUnlimitedContractSize: this.#options.chain
.allowUnlimitedContractSize,
blockchain: {
getBlock
} as any
});

// Listen to beforeTx and afterTx so we know when our target transaction
// is processing. These events will add the event listener for getting the trace data.
vm.on("beforeTx", beforeTxListener);
vm.on("afterTx", afterTxListener);

// Don't even let the vm try to flush the block's _cache to the stateTrie.
// When forking some of the data that the traced function may request will
// exist only on the main chain. Because we pretty much lie to the VM by
// telling it we DO have data in our Trie, when we really don't, it gets
// lost during the commit phase when it traverses the "borrowed" data's
// trie (as it may not have a valid root). Because this is a trace, and we
// don't need to commit the data, duck punching the `flush` method (the
// simplest method I could find) is fine.
// Remove this and you may see the infamous
// `Uncaught TypeError: Cannot read property 'pop' of undefined` error!
vm.stateManager._cache.flush = cb => cb();

// #3 - Process the block without committing the data.

// The vmerr key on the result appears to be removed.
// The previous implementation had specific error handling.
// It's possible we've removed handling specific cases in this implementation.
// e.g., the previous incatation of RuntimeError
await vm.runBlock({
block: newBlock, // .value is the object the vm expects
generate: true,
skipBlockValidation: true
});

// Just to be safe
removeListeners();

// #4 - send state results back
return {
gas,
structLogs,
returnValue
};
}

/**
* Gracefully shuts down the blockchain service and all of its dependencies.
*/
Expand Down
Loading

0 comments on commit 6569f70

Please sign in to comment.