UNPKG

tx2uml

Version:

Ethereum transaction visualizer that generates UML sequence diagrams.

532 lines 24.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TransactionManager = void 0; const ethers_1 = require("ethers"); const abi_1 = require("@ethersproject/abi"); const p_limit_1 = __importDefault(require("p-limit")); const config_1 = require("./config"); const tx2umlTypes_1 = require("./types/tx2umlTypes"); const labels_1 = require("./utils/labels"); const address_1 = require("@ethersproject/address"); const debug = require("debug")("tx2uml"); class TransactionManager { constructor(ethereumNodeClient, etherscanClient, // 3 works for smaller contracts but Etherscan will rate limit on larger contracts when set to 3 apiConcurrencyLimit = 1) { this.ethereumNodeClient = ethereumNodeClient; this.etherscanClient = etherscanClient; this.apiConcurrencyLimit = apiConcurrencyLimit; } async getTransactions(txHashes, network) { const transactions = []; for (const txHash of txHashes) { const tx = await this.getTransaction(txHash); transactions.push({ ...tx, network }); } return transactions; } async getTransaction(txHash) { return this.ethereumNodeClient.getTransactionDetails(txHash); } async getTraces(transactions) { const transactionsTraces = []; for (const transaction of transactions) { transactionsTraces.push(await this.ethereumNodeClient.getTransactionTrace(transaction.hash)); } return transactionsTraces; } static parseTransactionLogs(txHash, logs, contracts) { // for each tx log for (const log of logs) { // see if we have the contract source for the log const contract = contracts[log.address.toLowerCase()]; if (contract?.ethersContract) { // try and parse the log topic try { const event = contract.ethersContract.interface.parseLog(log); contract.events.push(parseEvent(txHash, contract, event)); } catch (err) { if (!contract?.delegatedToContracts) { debug(`Failed to parse log with topic ${log?.topics[0]} on contract ${log.address}`); continue; } // try to parse the event from the delegated to contracts for (const delegatedToContract of contract?.delegatedToContracts) { // try and parse the log topic try { const event = delegatedToContract.ethersContract.interface.parseLog(log); contract.events.push(parseEvent(txHash, contract, event)); // Found the event so no need to keep trying break; } catch (err) { debug(`Failed to parse log with topic ${log?.topics[0]} on contract ${log.address}`); } } } } } } async getContractsFromTraces(transactionsTraces, configFilename, abiFilename, network = "mainnet", mappedSource = []) { const flatTraces = transactionsTraces.flat(); const participantAddresses = []; // for each contract, maps all the contract addresses it can delegate to. // eg maps from a proxy contract to an implementation // or maps a contract calls for many libraries const delegatesToContracts = {}; for (const trace of Object.values(flatTraces)) { // duplicates are ok. They will be filtered later participantAddresses.push(trace.from); participantAddresses.push(trace.to); // If trace is a delegate call if (trace.type === tx2umlTypes_1.MessageType.DelegateCall) { // If not already mapped to calling contract if (!delegatesToContracts[trace.from]) { // Start a new list of contracts that are delegated to delegatesToContracts[trace.from] = [trace.to]; } else if ( // if contract has already been mapped and // contract it delegates to has not already added !delegatesToContracts[trace.from].includes(trace.to)) { // Add the contract being called to the existing list of contracts that are delegated to delegatesToContracts[trace.from].push(trace.to); } } } // Convert to a Set to remove duplicates and then back to an array const uniqueAddresses = Array.from(new Set(participantAddresses)); debug(`${uniqueAddresses.length} contracts in the transactions`); // Map any addresses to different source contract const remappedAddresses = uniqueAddresses.map(uniqueAddress => { const mapping = mappedSource.find(mapSource => mapSource.contract === uniqueAddress); return mapping ? mapping.source : uniqueAddress; }); // get contract ABIs from Etherscan const contracts = await this.getContractsFromAddresses(remappedAddresses); // Restore the mapping mappedSource.forEach(ms => { if (contracts[ms.source]) { contracts[ms.contract] = contracts[ms.source]; contracts[ms.contract].address = ms.contract; } }); // map the delegatedToContracts on each contract for (const [address, toAddresses] of Object.entries(delegatesToContracts)) { contracts[address].delegatedToContracts = // map the to addresses to Contract objects // with the address of the contract the delegate call is coming from toAddresses.map(toAddress => ({ ...contracts[toAddress], address, })); } // Get token name and symbol from chain await this.setTokenAttributes(contracts, network); // Override contract details like name, token symbol and ABI await this.configOverrides(contracts, configFilename, false); // Override abi information with a generic abi await this.fillContractsABIFromAddresses(contracts, uniqueAddresses, abiFilename); return contracts; } // Map contract ABI from generic abi async fillContractsABIFromAddresses(contracts, addresses, abiFilename) { const abis = await (0, config_1.loadGenericAbi)(abiFilename); const originalLog = console.log; console.log = function () { }; for (const address of addresses) { if (!contracts[address].ethersContract) { contracts[address].ethersContract = new ethers_1.Contract(address, abis); contracts[address].events = []; } } console.log = originalLog; } // Get the contract names and ABIs from Etherscan async getContractsFromAddresses(addresses) { const contracts = {}; // Get the contract details in parallel with a concurrency limit const limit = (0, p_limit_1.default)(this.apiConcurrencyLimit); const getContractPromises = addresses.map(address => { return limit(() => this.etherscanClient.getContract(address)); }); const results = await Promise.all(getContractPromises); results.forEach(result => { contracts[result.address] = result; }); return contracts; } async setTokenAttributes(contracts, network) { // get the token details const contractAddresses = Object.keys(contracts); const tokensDetails = await this.ethereumNodeClient.getTokenDetails(contractAddresses); const labels = (0, labels_1.loadLabels)(network); for (const [address, contract] of Object.entries(contracts)) { contract.labels = labels[address]?.labels; const tokenDetail = tokensDetails.find(td => td.address === address); contract.noContract = tokenDetail?.noContract; contract.tokenName = tokenDetail?.tokenName || labels[address]?.name; contract.symbol = tokenDetail?.tokenSymbol; contract.decimals = tokenDetail?.decimals; } } async getTransferParticipants(transactionsTransfers, block, network, configFilename, mapSource = []) { // Get a unique list of all accounts that transfer from, transfer to or are token contracts. const flatTransfers = transactionsTransfers.flat(); const addressSet = new Set(); flatTransfers.forEach(transfer => { addressSet.add(transfer.from); addressSet.add(transfer.to); if (transfer.tokenAddress) addressSet.add(transfer.tokenAddress); // If an ERC1155 transfer of a token if (ethers_1.utils.isAddress(transfer.tokenId?.toHexString())) addressSet.add((0, address_1.getAddress)(transfer.tokenId.toHexString())); }); const uniqueAddresses = Array.from(addressSet); // get token details from on-chain const tokenDetails = await this.ethereumNodeClient.getTokenDetails(uniqueAddresses); // Try and get Etherscan labels from local file const labels = (0, labels_1.loadLabels)(network); const participants = {}; for (const token of tokenDetails) { const address = token.address; participants[token.address] = { ...token, ...labels[address.toLowerCase()], }; if (!token.noContract) { // Check if the contract is proxied const implementation = await this.ethereumNodeClient.getProxyImplementation(address, block); // try and get contract name for the contract or its proxied implementation from Etherscan let sourceContract = implementation || address; // Remap source contract if configured const mappedSourceContract = mapSource.find(ms => ms.contract.toLowerCase() === sourceContract.toLowerCase()); if (mappedSourceContract) sourceContract = mappedSourceContract.source; const contract = await this.etherscanClient.getContract(sourceContract); participants[address].contractName = contract?.contractName; } } // Override contract details like name, token symbol and ABI await this.configOverrides(participants, configFilename, true); // Add the token symbol, name, decimal and nft flag to each transfer transactionsTransfers.forEach(transfers => { transfers.forEach(transfer => { if (!transfer.tokenAddress) { transfer.decimals = 18; return; } const tokenAddress = ethers_1.utils.isAddress(transfer.tokenId?.toHexString()) ? (0, address_1.getAddress)(transfer.tokenId.toHexString()) : transfer.tokenAddress; const participant = participants[tokenAddress]; if (participant) { transfer.tokenSymbol = participant.tokenSymbol; transfer.tokenName = participant.tokenName; transfer.decimals = participant.decimals; // if an NFT, move the value to the tokenId if (participant.nft) { transfer.tokenId = transfer.value; delete transfer.value; } } }); }); return participants; } static parseTraceParams(traces, contracts) { const functionSelector2Contract = mapFunctionSelectors2Contracts(contracts); for (const trace of traces.flat()) { if (trace.inputs?.length >= 10) { if (trace.type === tx2umlTypes_1.MessageType.Create) { trace.funcName = "constructor"; addConstructorParamsToTrace(trace, contracts); continue; } const selectedContracts = functionSelector2Contract[trace.funcSelector]; if (selectedContracts?.length > 0) { // get the contract for the function selector that matches the to address let contract = selectedContracts.find(contract => contract.address === trace.to); // if the function is not on the `to` contract, then its a proxy contract // so just use any contract if the function is on another contract if (!contract) { contract = selectedContracts[0]; trace.proxy = true; } try { const txDescription = contract.interface.parseTransaction({ data: trace.inputs, }); trace.funcName = txDescription.name; addInputParamsToTrace(trace, txDescription); addOutputParamsToTrace(trace, txDescription); } catch (err) { if (!err.message.match("no matching function")) { const error = new Error(`Failed to parse selector ${trace.funcSelector} in trace with id ${trace.id} from ${trace.from} to ${trace.to}`, { cause: err }); console.warn(error); } } } } } } async configOverrides(contracts, filename, encodedAddresses = true) { const configs = await (0, config_1.loadConfig)(filename); for (const [contractAddress, config] of Object.entries(configs)) { const address = encodedAddresses ? (0, address_1.getAddress)(contractAddress) : contractAddress.toLowerCase(); if (contracts[address]) { if (config.contractName) contracts[address].contractName = config.contractName; if (config.tokenName) contracts[address].tokenName = config.tokenName; if (config.tokenSymbol) contracts[address].tokenSymbol = config.tokenSymbol; if (config.decimals) contracts[address].decimals = config.decimals; if (config.protocolName) contracts[address].protocol = config.protocolName; if (config?.nft) contracts[address].nft = config?.nft; if (config.abi) { contracts[address].ethersContract = new ethers_1.Contract(address, config.abi); contracts[address].events = []; } } } } // Marks each contract the minimum call depth it is used in static parseTraceDepths(traces, contracts) { const flatTraces = traces.flat(); contracts[flatTraces[0].from].minDepth = 0; for (const trace of flatTraces) { if (contracts[trace.to].minDepth == undefined || trace.depth < contracts[trace.to].minDepth) { contracts[trace.to].minDepth = trace.depth; } } } // Filter out delegated calls from proxies to their implementations // and remove any excluded contracts static filterTransactionTraces(transactionTraces, contracts, options) { const filteredTransactionTraces = transactionTraces.map(t => []); let usedAddresses = new Set(); // For each transaction transactionTraces.forEach((tx, i) => { // recursively remove any calls to excluded contracts const filteredExcludedTraces = filterExcludedContracts(tx[0], options.excludedContracts); // recursively get a tree of traces without delegated calls const filteredTraces = options.noDelegates ? filterOutDelegatedTraces(filteredExcludedTraces) : [filteredExcludedTraces]; filteredTransactionTraces[i] = arrayifyTraces(filteredTraces[0]); // Add the tx sender to set of used addresses usedAddresses.add(filteredTransactionTraces[i][0].from); // Add all the to addresses of all the trades to the set of used addresses filteredTransactionTraces[i].forEach(t => usedAddresses.add(t.to)); }); // Filter out contracts that are no longer used from filtered out traces const usedContracts = {}; Array.from(usedAddresses).forEach(address => (usedContracts[address] = contracts[address])); return [filteredTransactionTraces, usedContracts]; } } exports.TransactionManager = TransactionManager; // Recursively filter out delegate calls from proxies or libraries depending on options const filterOutDelegatedTraces = (trace, lastValidParentTrace // there can be multiple traces removed ) => { // If parent trace was a proxy const removeTrace = trace.type === tx2umlTypes_1.MessageType.DelegateCall; const parentTrace = removeTrace ? // set to the last parent not removed lastValidParentTrace : // parent is not a proxy so is included { ...trace.parentTrace, type: tx2umlTypes_1.MessageType.Call }; // filter the child traces let filteredChildren = []; trace.childTraces.forEach(child => { filteredChildren = filteredChildren.concat(filterOutDelegatedTraces(child, parentTrace)); }); // if trace is being removed, return child traces so this trace is removed if (removeTrace) { return filteredChildren; } // else, attach child traces to copied trace and return in array return [ { ...trace, proxy: false, childTraces: filteredChildren, parentTrace: lastValidParentTrace, depth: (lastValidParentTrace?.depth || 0) + 1, delegatedFrom: trace.from, type: removeTrace ? tx2umlTypes_1.MessageType.Call : trace.type, }, ]; }; // Recursively filter out any calls to excluded contracts const filterExcludedContracts = (trace, excludedContracts = []) => { // filter the child traces let filteredChildren = []; trace.childTraces.forEach(child => { // If the child trace is a call to an excluded contract, then skip it if (excludedContracts.includes(child.to)) return; filteredChildren = filteredChildren.concat(filterExcludedContracts(child, excludedContracts)); }); return { ...trace, childTraces: filteredChildren, }; }; const arrayifyTraces = (trace) => { let traces = [trace]; trace.childTraces.forEach(child => { const arrayifiedChildren = arrayifyTraces(child); traces = traces.concat(arrayifiedChildren); }); return traces; }; // map of function selectors to Ethers Contracts const mapFunctionSelectors2Contracts = (contracts) => { // map of function selectors to Ethers Contracts const functionSelector2Contract = {}; // For each contract, get function selectors Object.values(contracts).forEach(contract => { if (contract.ethersContract) { Object.values(contract.ethersContract.interface.fragments) .filter(fragment => fragment.type === "function") .forEach((fragment) => { const sighash = contract.ethersContract.interface.getSighash(fragment); if (!functionSelector2Contract[sighash]) { functionSelector2Contract[sighash] = []; } functionSelector2Contract[sighash].push(contract.ethersContract); }); } }); return functionSelector2Contract; }; const addInputParamsToTrace = (trace, txDescription) => { // For each function argument, add to the trace input params txDescription.args.forEach((arg, i) => { const functionFragment = txDescription.functionFragment.inputs[i]; const components = addValuesToComponents(functionFragment, arg); trace.inputParams.push({ name: functionFragment.name, type: functionFragment.type, value: arg, components, }); }); }; const addOutputParamsToTrace = (trace, txDescription) => { // Undefined outputs can happen with failed transactions if (!trace.outputs || trace.outputs === "0x" || trace.error) return; const functionFragments = txDescription.functionFragment.outputs; const outputParams = abi_1.defaultAbiCoder.decode(functionFragments, trace.outputs); // For each output, add to the trace output params outputParams.forEach((param, i) => { const components = addValuesToComponents(functionFragments[i], param); trace.outputParams.push({ name: functionFragments[i].name, type: functionFragments[i].type, value: param, components, }); }); debug(`Decoded ${trace.outputParams.length} output params for ${trace.funcName} with selector ${trace.funcSelector}`); }; const addConstructorParamsToTrace = (trace, contracts) => { // Do we have the ABI for the deployed contract? const constructor = contracts[trace.to]?.ethersContract?.interface?.deploy; if (!constructor?.inputs) { // No ABI so we don't know the constructor params which comes from verified contracts on Etherscan return; } // we need this flag to determine if there was no constructor params or they are unknown trace.parsedConstructorParams = true; // if we don't have the ABI then we won't have the constructorInputs but we'll double check anyway if (!contracts[trace.to]?.constructorInputs?.length) { return; } const constructorParams = abi_1.defaultAbiCoder.decode(constructor.inputs, "0x" + contracts[trace.to]?.constructorInputs); // For each constructor param, add to the trace input params constructorParams.forEach((param, i) => { const components = addValuesToComponents(constructor.inputs[i], param); trace.inputParams.push({ name: constructor.inputs[i].name, type: constructor.inputs[i].type, value: param, components, }); }); debug(`Decoded ${trace.inputParams.length} constructor params.`); }; const parseEvent = (txHash, contract, log) => { const params = []; try { // For each event param log.eventFragment.inputs.forEach((param, i) => { const components = addValuesToComponents(param, log.args[i]); params.push({ name: log.eventFragment.inputs[i].name, type: log.eventFragment.inputs[i].type, value: log.args[i], components, }); }); return { name: log.name, txHash, params, }; } catch (err) { throw Error(`Failed to parse event ${log.name} on the ${contract.contractName} contract at ${contract.address} with error ${err}`); } }; // if function components exists, recursively add arg values to the function components const addValuesToComponents = (paramType, args) => { if (paramType.baseType !== "array") { if (!paramType?.components) { return undefined; } // For each component return paramType.components.map((component, j) => { // add the value and recursively add the components return { name: component.name, type: component.type, value: args[j], components: addValuesToComponents(component, args[j]), }; }); } else { // If an array of components return args.map((row, r) => { const components = addValuesToComponents({ ...paramType, type: paramType.arrayChildren?.type, baseType: paramType.arrayChildren?.baseType, }, row); return { name: r.toString(), type: paramType.arrayChildren?.type, value: row, components, }; }); } }; //# sourceMappingURL=transaction.js.map