UNPKG

tx2uml

Version:

Ethereum transaction visualizer that generates UML sequence diagrams.

327 lines 14.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.writeEvents = exports.genParams = exports.writeMessages = exports.writeParticipants = exports.streamSingleTxPuml = exports.streamMultiTxsPuml = exports.streamTxPlantUml = void 0; const stream_1 = require("stream"); const utils_1 = require("ethers/lib/utils"); const ethers_1 = require("ethers"); const transaction_1 = require("./transaction"); const formatters_1 = require("./utils/formatters"); const debug = require("debug")("tx2uml"); const DelegateLifelineColor = "#809ECB"; const DelegateMessageColor = "#3471CD"; const FailureFillColor = "#FFAAAA"; const streamTxPlantUml = (transactions, traces, contracts, options) => { const pumlStream = new stream_1.Readable({ read() { }, }); if (transactions.length > 1) { (0, exports.streamMultiTxsPuml)(pumlStream, transactions, traces, contracts, options); } else { (0, exports.streamSingleTxPuml)(pumlStream, transactions[0], traces[0], contracts, options); } return pumlStream; }; exports.streamTxPlantUml = streamTxPlantUml; const streamMultiTxsPuml = (pumlStream, transactions, traces, contracts, options = {}) => { pumlStream.push(`@startuml\n`); (0, exports.writeParticipants)(pumlStream, contracts, options); let i = 0; for (const transaction of transactions) { pumlStream.push(`\ngroup ${transaction.hash}`); writeTransactionDetails(pumlStream, transaction, options); (0, exports.writeMessages)(pumlStream, traces[i++], options); (0, exports.writeEvents)(pumlStream, contracts, options); pumlStream.push("end"); } pumlStream.push("\n@endumls"); pumlStream.push(null); return pumlStream; }; exports.streamMultiTxsPuml = streamMultiTxsPuml; const streamSingleTxPuml = (pumlStream, transaction, traces, contracts, options) => { pumlStream.push(`@startuml\ntitle ${transaction.hash}\n`); pumlStream.push(genCaption(transaction, options)); (0, exports.writeParticipants)(pumlStream, contracts, options); writeTransactionDetails(pumlStream, transaction, options); (0, exports.writeMessages)(pumlStream, traces, options); (0, exports.writeEvents)(pumlStream, contracts, options); pumlStream.push("\n@endumls"); pumlStream.push(null); return pumlStream; }; exports.streamSingleTxPuml = streamSingleTxPuml; const writeParticipants = (plantUmlStream, contracts, options = {}) => { plantUmlStream.push("\n"); // get the address of the first contract to output as the actor const [senderAddress] = Object.entries(contracts)[0]; plantUmlStream.push(`actor "${(0, formatters_1.shortAddress)(senderAddress)}" as ${(0, formatters_1.participantId)(senderAddress)}\n`); // output remaining contracts as participants for (const [address, contract] of Object.entries(contracts).slice(1)) { // Do not write contract as a participant if min depth greater than trace depth if (options.depth > 0 && contract.minDepth > options.depth) continue; let name = ""; if (contract.protocol) name += `<<${contract.protocol}>>`; if (contract.tokenName) name += `<<${contract.tokenName}>>`; if (contract.symbol) name += `<<(${contract.symbol})>>`; if (contract.contractName) name += `<<${contract.contractName}>>`; debug(`Write lifeline ${(0, formatters_1.shortAddress)(address)} with stereotype ${name}`); plantUmlStream.push(`participant "${(0, formatters_1.shortAddress)(address)}" as ${(0, formatters_1.participantId)(address)} ${name}\n`); } }; exports.writeParticipants = writeParticipants; const writeTransactionDetails = (plantUmlStream, transaction, options = {}) => { if (options.noTxDetails) { return; } plantUmlStream.push(`\nnote over ${(0, formatters_1.participantId)(transaction.from)}`); if (transaction.error) { plantUmlStream.push(` ${FailureFillColor}\nError: ${transaction.error} \n`); } else { // no error so will use default colour of tx details note plantUmlStream.push("\n"); } plantUmlStream.push(`Nonce: ${transaction.nonce.toLocaleString()}\n`); plantUmlStream.push(`Gas Price: ${(0, utils_1.formatUnits)(transaction.gasPrice, "gwei")} Gwei\n`); if (transaction.maxFeePerGas) { plantUmlStream.push(`Max Fee: ${(0, utils_1.formatUnits)(transaction.maxFeePerGas, "gwei")} Gwei\n`); } if (transaction.maxPriorityFeePerGas) { plantUmlStream.push(`Max Priority: ${(0, utils_1.formatUnits)(transaction.maxPriorityFeePerGas, "gwei")} Gwei\n`); } plantUmlStream.push(`Gas Limit: ${(0, formatters_1.formatNumber)(transaction.gasLimit.toString())}\n`); plantUmlStream.push(`Gas Used: ${(0, formatters_1.formatNumber)(transaction.gasUsed.toString())}\n`); const txFeeInWei = transaction.gasUsed.mul(transaction.gasPrice); const txFeeInEther = (0, utils_1.formatEther)(txFeeInWei); const tFeeInEtherFormatted = Number(txFeeInEther).toLocaleString(); plantUmlStream.push(`Tx Fee: ${tFeeInEtherFormatted} ETH\n`); plantUmlStream.push("end note\n"); }; const writeMessages = (plantUmlStream, traces, options = {}) => { if (!traces?.length) { return; } let contractCallStack = []; let previousTrace; plantUmlStream.push("\n"); // for each trace for (const trace of traces) { if (trace.depth > options.depth) continue; debug(`Write message ${trace.id} from ${(0, formatters_1.shortAddress)(trace.from)} to ${(0, formatters_1.shortAddress)(trace.to)}`); // return from lifeline if processing has moved to a different contract if (trace.delegatedFrom !== previousTrace?.to) { // contractCallStack is mutated in the loop so make a copy for (const callStack of [...contractCallStack]) { // stop returns when the callstack is back to this trace's lifeline if (trace.delegatedFrom === callStack.to) { break; } plantUmlStream.push(genEndLifeline(callStack, options)); contractCallStack.shift(); } } if (trace.type === transaction_1.MessageType.Selfdestruct) { plantUmlStream.push(`${(0, formatters_1.participantId)(trace.from)} ${genArrow(trace)} ${(0, formatters_1.participantId)(trace.from)}: Self-Destruct\n`); // TODO add ETH value transfer to refund address if there was a contract balance } else { plantUmlStream.push(`${(0, formatters_1.participantId)(trace.from)} ${genArrow(trace)} ${(0, formatters_1.participantId)(trace.to)}: ${genFunctionText(trace, options.noParams)}${genGasUsage(trace.gasUsed, options.noGas)}${genEtherValue(trace, options.noEther)}\n`); if (trace.type === transaction_1.MessageType.DelegateCall) { plantUmlStream.push(`activate ${(0, formatters_1.participantId)(trace.to)} ${DelegateLifelineColor}\n`); } else { plantUmlStream.push(`activate ${(0, formatters_1.participantId)(trace.to)}\n`); } } if (trace.type !== transaction_1.MessageType.Selfdestruct) { contractCallStack.unshift(trace); previousTrace = trace; } } contractCallStack.forEach(callStack => { plantUmlStream.push(genEndLifeline(callStack, options)); }); }; exports.writeMessages = writeMessages; const genEndLifeline = (trace, options = {}) => { let plantUml = ""; if (!trace.error) { if (options.noParams) { plantUml += `return\n`; } else { plantUml += `return${(0, exports.genParams)(trace.outputParams)}\n`; } if (!options.noGas && trace.childTraces.length > 0) { const gasUsedLessChildCalls = calculateGasUsedLessChildTraces(trace); if (gasUsedLessChildCalls?.gt(0)) { plantUml += `note right of ${(0, formatters_1.participantId)(trace.to)}: ${genGasUsage(gasUsedLessChildCalls)}\n`; } } } else { // a failed transaction so end the lifeline plantUml += `destroy ${(0, formatters_1.participantId)(trace.to)}\nreturn\n`; plantUml += `note right of ${(0, formatters_1.participantId)(trace.to)} ${FailureFillColor}: ${trace.error}\n`; } return plantUml; }; const calculateGasUsedLessChildTraces = (trace) => { // Sum gasUsed on all child traces of the parent let gasUsedLessChildTraces = ethers_1.BigNumber.from(0); for (const childTrace of trace.childTraces) { if (!childTrace.gasUsed) { return undefined; } gasUsedLessChildTraces = gasUsedLessChildTraces.add(childTrace.gasUsed); } return trace.gasUsed.sub(gasUsedLessChildTraces); }; const genArrow = (trace) => { const arrowColor = trace.parentTrace?.type === transaction_1.MessageType.DelegateCall ? `[${DelegateMessageColor}]` : ""; const line = trace.proxy ? "--" : "-"; if (trace.type === transaction_1.MessageType.DelegateCall) { return `${line}${arrowColor}>>`; } if (trace.type === transaction_1.MessageType.Create) { return `${line}${arrowColor}>o`; } if (trace.type === transaction_1.MessageType.Selfdestruct) { return `${line}${arrowColor}\\`; } // Call and Staticcall are the same return `${line}${arrowColor}>`; }; const genFunctionText = (trace, noParams = false) => { if (!trace) { return ""; } if (trace.type === transaction_1.MessageType.Create) { if (noParams) { return "constructor"; } // If we have the contract ABI so the constructor params could be parsed if (trace.parsedConstructorParams) { return `${trace.funcName}(${(0, exports.genParams)(trace.inputParams)})`; } // we don't know if there was constructor params or not as the contract was not verified on Etherscan // hence we don't have the constructor params or the contract ABI to parse them. return "constructor(?)"; } if (!trace.funcSelector) { return noParams ? "fallback" : "fallback()"; } if (!trace.funcName) { return `${trace.funcSelector}`; } return noParams ? trace.funcName : `${trace.funcName}(${(0, exports.genParams)(trace.inputParams)})`; }; const oneIndent = " "; const genParams = (params, plantUml = "", indent = "") => { if (!params) { return ""; } for (const param of params) { // put each param on a new line. // The \ needs to be escaped with \\ plantUml += "\\n" + indent; if (param.name) { plantUml += `${param.name}: `; } if (param.type === "address") { plantUml += `${(0, formatters_1.shortAddress)(param.value)},`; } else if (param.components) { if (Array.isArray(param.components)) { plantUml += `[`; plantUml = `${(0, exports.genParams)(param.components, plantUml, indent + oneIndent)}`; plantUml += `],`; } else { debug(`Unsupported components type ${JSON.stringify(param.components)}`); } } else if (Array.isArray(param.value)) { // not a component but an array of params plantUml += `[`; param.value.forEach((value, i) => { plantUml = `${(0, exports.genParams)([ { name: i.toString(), value, // remove the [] at the end of the type type: param.type.slice(0, -2), }, ], plantUml, indent + oneIndent)}`; }); plantUml += `],`; } else if (param.type.slice(0, 5) === "bytes") { plantUml += `${(0, formatters_1.shortBytes)(param.value)},`; } else if (param.type.match("int")) { plantUml += `${(0, formatters_1.formatNumber)(param.value)},`; } else { plantUml += `${param.value},`; } } return plantUml.slice(0, -1); }; exports.genParams = genParams; const genGasUsage = (gasUsed, noGasUsage = false) => { if (noGasUsage || !gasUsed) { return ""; } // Add thousand comma separators const gasValueWithCommas = (0, formatters_1.formatNumber)(gasUsed.toString()); return `\\n${gasValueWithCommas} gas`; }; const genEtherValue = (trace, noEtherValue = false) => { if (noEtherValue || trace.value.eq(0)) { return ""; } // Convert wei value to Ether const ether = (0, utils_1.formatEther)(trace.value); // Add thousand commas. Can't use formatNumber for this as it doesn't handle decimal numbers. // Assuming the amount of ether is not great than JS number limit. const etherFormatted = Number(ether).toLocaleString(); return `\\n${etherFormatted} ETH`; }; const genCaption = (details, options) => { return `caption ${options.chain || ""}, block ${details.blockNumber}, ${details.timestamp.toUTCString()}`; }; const writeEvents = (plantUmlStream, contracts, options = {}) => { if (options.noLogDetails) { return; } // For each contract let firstEvent = true; for (const contract of Object.values(contracts)) { if (contract.ethersContract && contract.events?.length && (options.depth === undefined || contract.minDepth <= options.depth)) { const align = firstEvent ? "" : "/ "; firstEvent = false; plantUmlStream.push(`\n${align}note over ${(0, formatters_1.participantId)(contract.address)} #aqua`); for (const event of contract.events) { plantUmlStream.push(`\n${event.name}:`); plantUmlStream.push(`${(0, exports.genParams)(event.params).replace(/\\n/g, "\n")}`); } plantUmlStream.push("\nend note"); } } }; exports.writeEvents = writeEvents; //# sourceMappingURL=plantUmlStreamer.js.map