From 4174f70c427e1fb81a24b4841ee8b53c29b25b18 Mon Sep 17 00:00:00 2001 From: Jack Hamer <47187316+JackHamer09@users.noreply.github.com> Date: Thu, 8 Feb 2024 15:06:25 +0200 Subject: [PATCH] feat: add transaction info command (#122) --- README.md | 5 +- docs/contract-interaction.md | 2 +- docs/transaction-info.md | 97 ++++++++ src/commands/contract/utils/helpers.ts | 25 +- src/commands/transaction/command.ts | 3 + src/commands/transaction/index.ts | 3 + src/commands/transaction/info.ts | 304 +++++++++++++++++++++++++ src/common/prompts.ts | 69 ++++++ src/index.ts | 1 + src/utils/formatters.ts | 54 +++++ 10 files changed, 540 insertions(+), 23 deletions(-) create mode 100644 docs/transaction-info.md create mode 100644 src/commands/transaction/command.ts create mode 100644 src/commands/transaction/index.ts create mode 100644 src/commands/transaction/info.ts create mode 100644 src/common/prompts.ts diff --git a/README.md b/README.md index b599a635..b102df35 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,14 @@ Run `npx zksync-cli dev` to see the full list of commands. - **Scripting**: Automated interactions and advanced zkSync operations using Node.js, with examples of wallet or contract interactions using viem or ethers. [Scripting Templates](https://github.com/matter-labs/zksync-scripting-templates#readme) ### Contract interaction commands +See full documentation and advanced examples [here](./docs/contract-interaction.md). - `npx zksync-cli contract read`: run read-only contract methods - `npx zksync-cli contract write`: send transactions to the contract - `npx zksync-cli contract encode`: get calldata from the contract method -See full documentation and advanced examples [here](./docs/contract-interaction.md). +### Transaction commands +See full documentation and advanced examples [here](./docs/transaction-info.md). +- `npx zksync-cli transaction info`: get information about a transaction ### Wallet commands - `npx zksync-cli wallet transfer`: send funds on L2 to another account diff --git a/docs/contract-interaction.md b/docs/contract-interaction.md index d9d7dff5..4cda5975 100644 --- a/docs/contract-interaction.md +++ b/docs/contract-interaction.md @@ -24,7 +24,7 @@ The `npx zksync-cli contract read` command executes read-only methods on contrac ### Read Options You do not need to specify options bellow, you will be prompted to enter them if they are not specified. -- `--chain `: Select the chain to use +- `--chain `: Select the chain to use (e.g., `zksync-mainnet`, `zksync-sepolia`). - `--rpc `: Provide RPC URL instead of selecting a chain - `--contract
`: Specify contract's address - `--method `: Defines the contract method to interact with diff --git a/docs/transaction-info.md b/docs/transaction-info.md new file mode 100644 index 00000000..fba6483c --- /dev/null +++ b/docs/transaction-info.md @@ -0,0 +1,97 @@ +# Transaction information + +The `npx zksync-cli transaction info` command is designed to fetch and display detailed information about a specific transaction. It can be used to check the status, amounts transferred, fees, method signatures, and arguments of transactions on the chain of choice. + +### Table of contents +- [Options](#options) +- [Examples](#example-usage) + - [Basic usage](#basic-usage) + - [Parsing transaction data](#parsing-transaction-data) + - [Viewing detailed information](#viewing-detailed-information) + - [Displaying raw JSON response](#displaying-raw-json-response) + +
+ +--- + +
+ +### Options +You do not need to specify options bellow, you will be prompted to enter them if they are not specified. + +- `--tx `: Specify the transaction hash to query. +- `--chain `: Select the chain to use (e.g., `zksync-mainnet`, `zksync-sepolia`). +- `--rpc `: Provide RPC URL instead of selecting a chain +- `--full`: Show all available transaction data for comprehensive insights. +- `--raw`: Display the raw JSON response from the node. +- `--abi `: Path to a local ABI file to decode the transaction's input data. + +If no options are provided directly, the CLI will prompt the user to enter the necessary information, such as the chain and transaction hash. + +
+ +--- + +
+ +## Example usage + +### Basic usage +```bash +npx zksync-cli transaction info +``` + +You will be prompted to select a chain and transaction hash. +```bash +? Chain to use: zkSync Sepolia Testnet +? Transaction hash: 0x2547ce8219eb7ed5d73e68673b0e4ded83afc732a6c651d43d9dc49bb2f13d40 +``` + +The command will then display detailed information about the transaction, including its status, from/to addresses, value transferred, method signature with arguments, and more: +``` +──────────────────── Main info ──────────────────── +Transaction hash: 0x2547ce8219eb7ed5d73e68673b0e4ded83afc732a6c651d43d9dc49bb2f13d40 +Status: completed +From: 0x56DDd604011c5F8629bd7C2472E3504Bd32c269b +To: 0xBB5c309A3a9347c0135B93CbD53D394Aa84345E5 +Value: 0 ETH +Fee: 0.0001503581 ETH | Initial: 0.0004 ETH Refunded: 0.0038496419 ETH +Method: transmit(bytes,bytes32[],bytes32[],bytes32) 0xc9807539 + +───────────────── Method arguments ───────────────── +[1] bytes: 0x0000000000000000000000fd69e45d6f51e482ac4f8f2e14f2155200008b5f010001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000007df298c81a0000000000000000000000000000000000000000000000000000007df298c81a0000000000000000000000000000000000000000000000000000007df298c81a +[2] bytes32[]: 0xd737d65b6b610c3f330bcfddbfc08e46d2a628c88bf22ec0d8f25627a3330798,0x9d33be2ba33b731555c13a4e7bf02d3d576fa3115f7523cbf07732321c85cdba +[3] bytes32[]: 0x73d639deda36b781ae049c8eceafba4196ee8ecc1efb74c538a28ea762ff6658,0x37ac79ff2ca902140613b0e51357d8fb218a67b4736bdee0c268c5fd9812e146 +[4] bytes32: 0x0101000000000000000000000000000000000000000000000000000000000000 + +───────────────────── Details ───────────────────── +Date: 2/8/2024, 2:19:54 PM (15 minutes ago) +Block: #364999 +Nonce: 50131 +``` + +### Parsing transaction data +By default `zksync-cli` tries to fetch contract verification data from the server. +In case this is not possible it queries the [open signature](https://www.4byte.directory/) database to get signature of the transaction method. +If the method signature is not found, the transaction's data is displayed as a hex string. + + +Alternatively, you can provide the path to a local ABI file to decode the transaction's input data: +```bash +npx zksync-cli transaction info \ + --abi "./Greeter.json" +``` + +### Viewing detailed information +For an even more detailed overview you can use the `--full` option: + +```bash +npx zksync-cli transaction info --full +``` + +### Displaying raw JSON response +If you prefer to see the raw JSON response from the zkSync node, use the `--raw` option: + +```bash +npx zksync-cli transaction info --raw +``` \ No newline at end of file diff --git a/src/commands/contract/utils/helpers.ts b/src/commands/contract/utils/helpers.ts index b3b579b2..373e8839 100644 --- a/src/commands/contract/utils/helpers.ts +++ b/src/commands/contract/utils/helpers.ts @@ -7,6 +7,7 @@ import ora from "ora"; import { getMethodId } from "./formatters.js"; import { getProxyImplementation } from "./proxy.js"; import { fileOrDirExists } from "../../../utils/files.js"; +import { formatSeparator } from "../../../utils/formatters.js"; import Logger from "../../../utils/logger.js"; import type { L2Chain } from "../../../data/chains.js"; @@ -144,24 +145,6 @@ export const askAbiMethod = async ( return "manual"; } - const formatSeparator = (text: string): DistinctChoice => { - const totalLength = 50; // Total length of the line including the text - - if (!text) { - return { - type: "separator", - line: "─".repeat(totalLength + 1), - }; - } - - const textLength = text.length; - const dashLength = (totalLength - textLength) / 2; - const dashes = "─".repeat(dashLength); - return { - type: "separator", - line: `${dashes} ${text} ${dashes}`, - }; - }; const formatFragment = (fragment: ethers.utils.FunctionFragment): DistinctChoice => { let name = fragment.format(ethers.utils.FormatTypes.full); if ((type === "write" || type === "any") && name.includes(" returns ")) { @@ -180,7 +163,7 @@ export const askAbiMethod = async ( noMethods: { type: "separator", line: chalk.white("No methods found") } as DistinctChoice, contractNotVerified: { type: "separator", line: chalk.white("Contract is not verified") } as DistinctChoice, }; - choices.push(formatSeparator("Provided contract")); + choices.push(formatSeparator("Provided contract") as DistinctChoice); if (contractInfo.abi) { const methods = getMethodsFromAbi(contractInfo.abi, type); if (methods.length) { @@ -199,7 +182,7 @@ export const askAbiMethod = async ( } if (contractInfo?.implementation) { if (contractInfo.implementation.abi) { - choices.push(formatSeparator("Resolved implementation")); + choices.push(formatSeparator("Resolved implementation") as DistinctChoice); const implementationMethods = getMethodsFromAbi(contractInfo.implementation.abi, type); if (implementationMethods.length) { choices.push(...implementationMethods.map(formatFragment)); @@ -217,7 +200,7 @@ export const askAbiMethod = async ( } } - choices.push(formatSeparator("")); + choices.push(formatSeparator("") as DistinctChoice); choices.push({ name: "Type method manually", value: "manual", diff --git a/src/commands/transaction/command.ts b/src/commands/transaction/command.ts new file mode 100644 index 00000000..f7f70dec --- /dev/null +++ b/src/commands/transaction/command.ts @@ -0,0 +1,3 @@ +import Program from "../../program.js"; + +export default Program.command("transaction").description("Transactions related functionality"); diff --git a/src/commands/transaction/index.ts b/src/commands/transaction/index.ts new file mode 100644 index 00000000..e0256463 --- /dev/null +++ b/src/commands/transaction/index.ts @@ -0,0 +1,3 @@ +import "./info.js"; + +import "./command.js"; // registers all the commands above diff --git a/src/commands/transaction/info.ts b/src/commands/transaction/info.ts new file mode 100644 index 00000000..3e899d00 --- /dev/null +++ b/src/commands/transaction/info.ts @@ -0,0 +1,304 @@ +import chalk from "chalk"; +import { Option } from "commander"; +import { BigNumber, ethers } from "ethers"; +import inquirer from "inquirer"; +import ora from "ora"; +import { utils } from "zksync-ethers"; +import { BOOTLOADER_FORMAL_ADDRESS } from "zksync-ethers/build/src/utils.js"; + +import Program from "./command.js"; +import { chainOption, l2RpcUrlOption } from "../../common/options.js"; +import { promptChain } from "../../common/prompts.js"; +import { bigNumberToDecimal, convertBigNumbersToStrings, formatSeparator, getTimeAgo } from "../../utils/formatters.js"; +import { getL2Provider, optionNameToParam } from "../../utils/helpers.js"; +import Logger from "../../utils/logger.js"; +import { isTransactionHash } from "../../utils/validators.js"; +import { abiOption } from "../contract/common/options.js"; +import { getContractInformation, readAbiFromFile } from "../contract/utils/helpers.js"; + +import type { L2Chain } from "../../data/chains.js"; +import type { Provider } from "zksync-ethers"; +import type { TransactionReceipt } from "zksync-ethers/src/types.js"; + +type TransactionInfoOptions = { + chain?: string; + rpc?: string; + transaction?: string; + full?: boolean; + raw?: boolean; + abi?: string; +}; + +const transactionHashOption = new Option("--tx, --transaction ", "Transaction hash"); +const fullOption = new Option("--full", "Show all available data"); +const rawOption = new Option("--raw", "Show raw JSON response"); + +export const handler = async (options: TransactionInfoOptions) => { + const getTransactionFeeData = (receipt: TransactionReceipt) => { + const transfers: { amount: BigNumber; from: string; to: string }[] = []; + receipt.logs.forEach((log) => { + try { + const parsed = utils.IERC20.decodeEventLog("Transfer", log.data, log.topics); + transfers.push({ + from: parsed.from, + to: parsed.to, + amount: parsed.value, + }); + } catch { + // ignore + } + }); + const totalFee = receipt.gasUsed.mul(receipt.effectiveGasPrice); + const refunded = transfers.reduce((acc, transfer) => { + if (transfer.from === BOOTLOADER_FORMAL_ADDRESS) { + return acc.add(transfer.amount); + } + return acc; + }, BigNumber.from("0")); + + return { + refunded, + totalFee, + paidByPaymaster: + !transfers.length || + receipt.from !== transfers.find((transfer) => transfer.from === BOOTLOADER_FORMAL_ADDRESS)?.to, + }; + }; + const getDecodedMethodSignature = async (hexSignature: string) => { + if (hexSignature === "0x") { + return; + } + return await fetch(`https://www.4byte.directory/api/v1/signatures/?format=json&hex_signature=${hexSignature}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then((data) => data?.results?.[0]?.text_signature) + .catch(() => undefined); + }; + const getAddressAndMethodInfo = async (address: string, calldata: string, provider: Provider, chain: L2Chain) => { + let contractInfo = await getContractInformation(chain, provider, address, { fetchImplementation: true }).catch( + () => undefined + ); + const hexSignature = calldata.slice(0, 10); + let decodedSignature: string | undefined; + let decodedArgs: { name?: string; type: string; value: string }[] | undefined; + if (options.abi) { + if (!contractInfo) { + contractInfo = { + address, + bytecode: "0x", + abi: readAbiFromFile(options.abi), + }; + } else { + contractInfo.abi = readAbiFromFile(options.abi); + } + } + if (contractInfo?.abi || contractInfo?.implementation?.abi) { + const initialAddressInterface = new ethers.utils.Interface(contractInfo?.abi || []); + const implementationInterface = new ethers.utils.Interface(contractInfo?.implementation?.abi || []); + const matchedMethod = + initialAddressInterface.getFunction(hexSignature) || implementationInterface.getFunction(hexSignature); + if (matchedMethod) { + decodedSignature = matchedMethod.format(ethers.utils.FormatTypes.full); + if (decodedSignature.startsWith("function")) { + decodedSignature = decodedSignature.slice("function".length + 1); + } + } + } + + if (!decodedSignature) { + decodedSignature = await getDecodedMethodSignature(hexSignature); + } + + if (decodedSignature) { + try { + const contractInterface = new ethers.utils.Interface([`function ${decodedSignature}`]); + const inputs = contractInterface.getFunction(hexSignature).inputs; + const encodedArgs = calldata.slice(10); + const decoded = ethers.utils.defaultAbiCoder.decode(inputs, `0x${encodedArgs}`); + decodedArgs = inputs.map((input, index) => { + return { + name: input.name, + type: input.type, + value: decoded[index]?.toString(), + }; + }); + } catch { + // ignore + } + } + + return { + contractInfo, + hexSignature, + decodedSignature, + decodedArgs, + }; + }; + + try { + const chain = await promptChain( + { + message: chainOption.description, + name: optionNameToParam(chainOption.long!), + }, + undefined, + options + ); + + const answers: TransactionInfoOptions = await inquirer.prompt( + [ + { + message: transactionHashOption.description, + name: optionNameToParam(transactionHashOption.long!), + type: "input", + required: true, + validate: (input: string) => isTransactionHash(input), + }, + ], + options + ); + options = { + ...options, + ...answers, + }; + + const l2Provider = getL2Provider(options.rpc ?? chain!.rpcUrl); + const spinner = ora("Looking for transaction...").start(); + try { + const [transactionData, transactionDetails, transactionReceipt] = await Promise.all([ + l2Provider.getTransaction(options.transaction!), + l2Provider.getTransactionDetails(options.transaction!), + l2Provider.getTransactionReceipt(options.transaction!), + ]); + if (!transactionData) { + throw new Error("Transaction not found"); + } + const { + contractInfo, + hexSignature: methodHexSignature, + decodedSignature: methodDecodedSignature, + decodedArgs: methodDecodedArgs, + } = await getAddressAndMethodInfo(transactionData.to!, transactionData.data, l2Provider, chain); + spinner.stop(); + if (options.raw) { + Logger.info( + JSON.stringify( + convertBigNumbersToStrings(transactionReceipt || transactionDetails || transactionData), + null, + 2 + ), + { + noFormat: true, + } + ); + return; + } + + Logger.info(formatSeparator("Main info").line, { noFormat: true }); + let logString = ""; + /* Main */ + logString += `Transaction hash: ${transactionData.hash}`; + logString += "\nStatus: "; + if (transactionDetails?.status === "failed") { + logString += chalk.redBright("failed"); + } else if (transactionDetails?.status === "included" || transactionDetails?.status === "verified") { + logString += chalk.greenBright("completed"); + } else { + logString += transactionDetails?.status || chalk.gray("N/A"); + } + logString += `\nFrom: ${transactionData.from}`; + logString += `\nTo: ${transactionData.to}`; + if (contractInfo?.implementation) { + logString += chalk.gray(" |"); + logString += chalk.gray(` Implementation: ${contractInfo.implementation.address}`); + } + logString += `\nValue: ${bigNumberToDecimal(transactionData.value)} ETH`; + + const initialFee = transactionData.gasLimit.mul(transactionData.gasPrice!); + const feeData = transactionReceipt ? getTransactionFeeData(transactionReceipt) : undefined; + logString += `\nFee: ${bigNumberToDecimal(feeData?.totalFee || initialFee)} ETH`; + if (feeData?.paidByPaymaster) { + logString += chalk.gray(" (paid by paymaster)"); + } + if (feeData) { + logString += chalk.gray(" |"); + logString += chalk.gray(` Initial: ${bigNumberToDecimal(initialFee)} ETH`); + logString += chalk.gray(` Refunded: ${bigNumberToDecimal(feeData.refunded)} ETH`); + } + + logString += "\nMethod: "; + if (methodDecodedSignature) { + logString += `${methodDecodedSignature} `; + } + if (methodHexSignature !== "0x") { + logString += chalk.gray(methodHexSignature); + } else { + logString += chalk.gray("N/A"); + } + Logger.info(logString, { noFormat: true }); + logString = ""; + + if (methodDecodedArgs) { + Logger.info(`\n${formatSeparator("Method arguments").line}`, { noFormat: true }); + methodDecodedArgs.forEach((arg, index) => { + if (index !== 0) { + logString += "\n"; + } + logString += `[${index + 1}] `; + if (arg.name) { + logString += `${arg.name} ${chalk.gray(`(${arg.type})`)}: `; + } else { + logString += `${chalk.gray(arg.type)}: `; + } + logString += arg.value; + }); + Logger.info(logString, { noFormat: true }); + logString = ""; + } + + Logger.info(`\n${formatSeparator("Details").line}`, { noFormat: true }); + logString += "Date: "; + let transactionDate: Date | undefined; + if (transactionData.timestamp) { + transactionDate = new Date(transactionData.timestamp); + } else if (transactionDetails?.receivedAt) { + transactionDate = new Date(transactionDetails.receivedAt); + } + if (transactionDate) { + logString += transactionDate.toLocaleString(); + logString += chalk.gray(` (${getTimeAgo(transactionDate)})`); + } else { + logString += chalk.gray("N/A"); + } + logString += `\nBlock: #${transactionData.blockNumber}`; + logString += `\nNonce: ${transactionData.nonce}`; + if (options.full) { + logString += `\nTransaction type: ${transactionData.type}`; + logString += `\nEthereum commit hash: ${transactionDetails?.ethCommitTxHash || chalk.gray("in progress")}`; + logString += `\nEthereum prove hash: ${transactionDetails?.ethProveTxHash || chalk.gray("in progress")}`; + logString += `\nEthereum execute hash: ${transactionDetails?.ethExecuteTxHash || chalk.gray("in progress")}`; + } + Logger.info(logString, { noFormat: true }); + } finally { + spinner.stop(); + } + } catch (error) { + Logger.error("There was an error getting transaction info:"); + Logger.error(error); + throw error; + } +}; + +Program.command("info") + .description("Get transaction info") + .addOption(transactionHashOption) + .addOption(chainOption) + .addOption(l2RpcUrlOption) + .addOption(fullOption) + .addOption(rawOption) + .addOption(abiOption) + .action(handler); diff --git a/src/common/prompts.ts b/src/common/prompts.ts new file mode 100644 index 00000000..ec6f8f30 --- /dev/null +++ b/src/common/prompts.ts @@ -0,0 +1,69 @@ +import chalk from "chalk"; +import inquirer from "inquirer"; + +import { getChains, promptAddNewChain } from "../commands/config/chains.js"; +import { l2Chains } from "../data/chains.js"; +import { formatSeparator } from "../utils/formatters.js"; + +import type { L2Chain } from "../data/chains.js"; + +export const promptChain = async | undefined>( + prompt: { message: string; name: string }, + chains?: { filter?: (chain: L2Chain) => boolean }, + options?: T +): Promise => { + const customChains = getChains(); + const allChains = [...l2Chains, ...customChains]; + + if (options?.[prompt.name]) { + const chain = allChains.find((chain) => chain.network === options[prompt.name]); + if (chain) { + return chain; + } else { + throw new Error(`Chain "${options[prompt.name]}" wasn't found`); + } + } + + const answers = await inquirer.prompt( + [ + { + message: prompt.message, + name: prompt.name, + type: "list", + loop: false, + choices: [ + ...l2Chains.filter(chains?.filter || (() => true)).map((chain) => ({ + name: chain.name, + value: chain.network, + })), + formatSeparator("Custom chains"), + ...customChains.filter(chains?.filter || (() => true)).map((chain) => ({ + name: chain.name + chalk.gray(` - ${chain.network}`), + value: chain.network, + })), + { + name: chalk.greenBright("+") + " Add new chain", + short: "Add new chain", + value: "add-new-chain", + }, + ], + required: true, + }, + ], + options + ); + const response = answers[prompt.name]; + + let chain: L2Chain | undefined; + if (response === "add-new-chain") { + chain = await promptAddNewChain(); + } else { + chain = allChains.find((chain) => chain.network === response)!; + } + + if (options) { + options[prompt.name] = chain.network; + } + + return chain; +}; diff --git a/src/index.ts b/src/index.ts index a31a70d6..539b0b22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import Program from "./program.js"; import "./commands/dev/index.js"; import "./commands/contract/index.js"; +import "./commands/transaction/index.js"; import "./commands/create/index.js"; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index df121c2c..87b2a306 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { BigNumber } from "ethers"; import { formatUnits, parseUnits } from "ethers/lib/utils.js"; import { hasColor } from "./helpers.js"; @@ -69,3 +70,56 @@ export const formatSeparator = (text: string) => { line: `${dashes} ${text} ${dashes}`, }; }; + +export const getTimeAgo = (date: Date): string => { + const now = new Date(); + const secondsDiff = Math.floor((now.getTime() - date.getTime()) / 1000); + + const years = Math.floor(secondsDiff / (60 * 60 * 24 * 365)); // seconds in a year + if (years >= 1) { + return years + " years ago"; + } + + const months = Math.floor(secondsDiff / (60 * 60 * 24 * 30)); // seconds in a month + if (months >= 1) { + return months + " months ago"; + } + + const days = Math.floor(secondsDiff / (60 * 60 * 24)); // seconds in a day + if (days >= 1) { + return days + " days ago"; + } + + const hours = Math.floor(secondsDiff / (60 * 60)); // seconds in an hour + if (hours >= 1) { + return hours + " hours ago"; + } + + const minutes = Math.floor(secondsDiff / 60); // seconds in a minute + if (minutes >= 1) { + return minutes + " minutes ago"; + } + + return secondsDiff + " seconds ago"; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const convertBigNumbersToStrings = (value: any): any => { + if (BigNumber.isBigNumber(value)) { + return value.toString(); + } + // Handle arrays recursively + else if (Array.isArray(value)) { + return value.map(convertBigNumbersToStrings); + } + // Handle objects recursively + else if (typeof value === "object" && value !== null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const convertedObject: any = {}; + Object.keys(value).forEach((key) => { + convertedObject[key] = convertBigNumbersToStrings(value[key]); + }); + return convertedObject; + } + return value; +};