import axios from "axios" import { BigNumber } from "ethers" import { VError } from "verror" import { MessageType, Trace, TransactionDetails } from "../transaction" import { transactionHash } from "../utils/regEx" import { hexlify, toUtf8String } from "ethers/lib/utils" import EthereumNodeClient from "./EthereumNodeClient" require("axios-debug-log") const debug = require("debug")("tx2uml") export type CallResponse = { type: | "CALL" | "CALLCODE" | "CREATE" | "CREATE2" | "DELEGATECALL" | "SELFDESTRUCT" | "STATICCALL" from?: string to?: string input?: string output?: string value?: string gas?: string gasUsed?: string time?: string error?: string calls?: CallResponse[] } export default class GethClient extends EthereumNodeClient { private jsonRpcId = 0 constructor( public readonly url: string = "http://localhost:8545", public readonly chain = "mainnet" ) { super(url, chain) } async getTransactionTrace(txHash: string): Promise<Trace[]> { if (!txHash?.match(transactionHash)) { throw new TypeError( `Transaction hash "${txHash}" must be 32 bytes in hexadecimal format with a 0x prefix` ) } try { debug(`About to get transaction trace for ${txHash}`) const response = await axios.post(this.url, { id: this.jsonRpcId++, jsonrpc: "2.0", method: "debug_traceTransaction", params: [txHash, { tracer: "callTracer" }], }) if (response.data?.error?.message) { throw new Error(response.data.error.message) } if (!response?.data?.result?.from) { if (response?.data?.result?.structLogs) { throw new Error( `Have you set the --nodeType option correctly? It looks like a debug_traceTransaction was run against a node that doesn't support tracing in their debugging API.` ) } throw new Error( `no transaction trace messages in response. ${response?.data?.result}` ) } // recursively add the traces const traces: Trace[] = [] addTraces(response.data.result, traces, 0, 0) debug( `Got ${traces.length} traces actions for tx hash ${txHash} from ${this.url}` ) return traces } catch (err) { throw new VError( err, `Failed to get transaction trace for tx hash ${txHash} from url ${this.url}.` ) } } async getTransactionError(tx: TransactionDetails): Promise<string> { if (!tx?.hash.match(transactionHash)) { throw TypeError( `There is no transaction hash on the receipt object` ) } if (tx.status) { return undefined } if (tx.gasUsed === tx.gasLimit) { throw Error("Transaction failed as it ran out of gas.") } let rawMessageData try { const params = [ { nonce: tx.nonce, gasPrice: convertBigNumber2Hex(tx.gasPrice), gas: convertBigNumber2Hex(tx.gasLimit), value: convertBigNumber2Hex(tx.value), from: tx.from, to: tx.to, data: tx.data, }, // need to call for the block before hexlify(tx.blockNumber - 1).replace(/^0x0/, "0x"), ] const response = await axios.post(this.url, { id: this.jsonRpcId++, jsonrpc: "2.0", method: "eth_call", params, }) return response.data?.error?.message } catch (e) { if (e.message.startsWith("Node error: ")) { // Trim "Node error: " const errorObjectStr = e.message.slice(12) // Parse the error object const errorObject = JSON.parse(errorObjectStr) if (!errorObject.data) { throw Error( "Failed to parse data field error object:" + errorObjectStr ) } if (errorObject.data.startsWith("Reverted 0x")) { // Trim "Reverted 0x" from the data field rawMessageData = errorObject.data.slice(11) } else if (errorObject.data.startsWith("0x")) { // Trim "0x" from the data field rawMessageData = errorObject.data.slice(2) } else { throw Error( "Failed to parse data field of error object:" + errorObjectStr ) } } else { throw Error( "Failed to parse error message from Ethereum call: " + e.message ) } } return parseReasonCode(rawMessageData) } } // Adds calls from a Geth debug_traceTransaction API response to the traces const addTraces = ( callResponse: CallResponse, traces: Trace[], id: number, depth: number, parentTrace?: Trace ): number => { const type = convertType(callResponse) const delegatedFrom = parentTrace?.type === MessageType.DelegateCall ? parentTrace.to : callResponse.from const newTrace: Trace = { id: id++, type, from: callResponse.from, delegatedFrom, to: callResponse.to, value: callResponse.value ? convertBigNumber(callResponse.value) : BigNumber.from(0), // remove trailing 64 zeros inputs: callResponse.input, inputParams: [], // Will init later once we have the contract ABI funcSelector: callResponse.input?.length >= 10 ? callResponse.input.slice(0, 10) : undefined, outputs: callResponse.output, outputParams: [], // Will init later once we have the contract ABI gasLimit: convertBigNumber(callResponse.gas), gasUsed: convertBigNumber(callResponse.gasUsed), parentTrace, childTraces: [], depth, error: callResponse.error, } if (parentTrace) { parentTrace.childTraces.push(newTrace) } traces.push(newTrace) if (callResponse.calls) { callResponse.calls.forEach(childCall => { // recursively add traces id = addTraces(childCall, traces, id, depth + 1, newTrace) }) } return id } const convertType = (trace: CallResponse): MessageType => { let type: MessageType = MessageType.Call if (trace.type === "DELEGATECALL") { return MessageType.DelegateCall } if (trace.type === "STATICCALL") { return MessageType.StaticCall } if (trace.type === "CREATE" || trace.type === "CREATE2") { return MessageType.Create } else if (trace.type === "SELFDESTRUCT") { return MessageType.Selfdestruct } return type } // convert an integer value to a decimal value. eg wei to Ethers which is to 18 decimal places const convertBigNumber = (value: string): BigNumber | undefined => { if (!value) return undefined return BigNumber.from(value) } const convertBigNumber2Hex = (value: BigNumber) => { return value.toHexString().replace(/^0x0/, "0x") } export const parseReasonCode = (messageData: string): string => { // Get the length of the revert reason const strLen = parseInt(messageData.slice(8 + 64, 8 + 128), 16) // Using the length and known offset, extract and convert the revert reason const reasonCodeHex = messageData.slice(8 + 128, 8 + 128 + strLen * 2) // Convert reason from hex to string const reason = toUtf8String("0x" + reasonCodeHex) return reason }