diff --git a/.changeset/chatty-laws-mate.md b/.changeset/chatty-laws-mate.md new file mode 100644 index 0000000000..d3b37e0aaf --- /dev/null +++ b/.changeset/chatty-laws-mate.md @@ -0,0 +1,7 @@ +--- +"@kadena/kadena-cli": minor +--- + +- Updated "tx send" command to include transaction logging functionality. Transactions are now saved in a log file, capturing network details, transaction ID, and status. + +- Implemented the tx history command to display a formatted transaction history. This command provides a user-friendly way to view detailed transaction logs, including network host, network ID, chain ID, status, and transaction ID. diff --git a/packages/tools/kadena-cli/src/commands/tx/commands/txHistory.ts b/packages/tools/kadena-cli/src/commands/tx/commands/txHistory.ts new file mode 100644 index 0000000000..eeed256b2a --- /dev/null +++ b/packages/tools/kadena-cli/src/commands/tx/commands/txHistory.ts @@ -0,0 +1,159 @@ +import { createClient } from '@kadena/client'; +import type { Command } from 'commander'; +import path from 'node:path'; +import { TRANSACTIONS_LOG_FILE } from '../../../constants/config.js'; +import { KadenaError } from '../../../services/service-error.js'; +import { createCommand } from '../../../utils/createCommand.js'; +import { notEmpty } from '../../../utils/globalHelpers.js'; +import { globalOptions } from '../../../utils/globalOptions.js'; +import { log } from '../../../utils/logger.js'; +import { createTable } from '../../../utils/table.js'; +import type { + ITransactionLog, + ITransactionLogEntry, + IUpdateTransactionsLogPayload, +} from '../utils/txHelpers.js'; +import { + formatDate, + generateClientUrl, + getTransactionDirectory, + mergePayloadsWithTransactionLog, + readTransactionLog, + updateTransactionStatus, +} from '../utils/txHelpers.js'; + +const header = { + requestKey: 'Request Key', + networkHost: 'Network Host', + networkId: 'Network ID', + chainId: 'Chain ID', + dateTime: 'Time', + status: 'Status', + txId: 'Transaction ID', +}; + +const filterLogsWithoutStatus = (log: ITransactionLog): ITransactionLog[] => { + return Object.entries(log) + .filter(([, logData]) => !logData.status) + .map(([key, value]) => ({ [key]: value })); +}; + +const getTransactionStatus = async ( + requestKey: string, + value: ITransactionLogEntry, +): Promise => { + const { getStatus } = createClient( + generateClientUrl({ + networkId: value.networkId, + chainId: value.chainId, + networkHost: value.networkHost, + }), + ); + + try { + const result = await getStatus({ + requestKey, + chainId: value.chainId, + networkId: value.networkId, + }); + + if (result[requestKey] !== undefined) { + return { + requestKey, + status: result[requestKey].result.status, + data: result[requestKey], + }; + } + } catch (e) { + log.error( + `Failed to get transaction status for requestKey "${requestKey}": ${e.message}`, + ); + } +}; + +const fetchTransactionStatuses = async ( + logs: ITransactionLog[], +): Promise => { + return await Promise.all( + logs.map(async (logData) => { + for (const [requestKey, value] of Object.entries(logData)) { + const status = await getTransactionStatus(requestKey, value); + if (status) { + return status; + } + } + }), + ).then((results) => results.filter(notEmpty)); +}; + +export const printTxLogs = (transactionLog: ITransactionLog): void => { + const table = createTable({}); + Object.entries(transactionLog).forEach(([requestKey, data]) => { + const tableData: Record = { + requestKey, + networkHost: data.networkHost, + networkId: data.networkId, + chainId: data.chainId, + dateTime: formatDate(new Date(data.dateTime)), + status: data.status, + txId: data.txId?.toString() ?? undefined, + }; + + Object.entries(tableData).forEach(([key, value]) => { + if (value !== undefined) { + const headerKey = header[key as keyof typeof header]; + table.push({ + [log.color.green(headerKey)]: value, + }); + } + }); + table.push([{ colSpan: 2, content: '' }]); + }); + + log.output(table.toString(), transactionLog); +}; + +export const txHistory = async (): Promise => { + try { + const transactionDir = getTransactionDirectory(); + if (!notEmpty(transactionDir)) throw new KadenaError('no_kadena_directory'); + + const transactionFilePath = path.join( + transactionDir, + TRANSACTIONS_LOG_FILE, + ); + let transactionLog = await readTransactionLog(transactionFilePath); + if (!transactionLog) + throw new Error( + 'No transaction logs are available. Please ensure that transaction logs are present and try again.', + ); + + const filteredLogs = filterLogsWithoutStatus(transactionLog); + + if (filteredLogs.length > 0) { + const txResultsData = await fetchTransactionStatuses(filteredLogs); + await updateTransactionStatus(txResultsData); + transactionLog = mergePayloadsWithTransactionLog( + transactionLog, + txResultsData, + ); + } + + printTxLogs(transactionLog); + } catch (error) { + log.error(`Unable to read transaction history: ${error.message}`); + } +}; + +export const createTxHistoryCommand: ( + program: Command, + version: string, +) => void = createCommand( + 'history', + 'Output a formatted list of transactions with their details, making it easy for users to understand their transaction history.', + [globalOptions.directory({ disableQuestion: true })], + async () => { + log.debug('tx-history:action'); + await txHistory(); + }, +); diff --git a/packages/tools/kadena-cli/src/commands/tx/commands/txLocal.ts b/packages/tools/kadena-cli/src/commands/tx/commands/txLocal.ts index 28972ef42a..4e58160c32 100644 --- a/packages/tools/kadena-cli/src/commands/tx/commands/txLocal.ts +++ b/packages/tools/kadena-cli/src/commands/tx/commands/txLocal.ts @@ -73,7 +73,6 @@ export const createTxLocalCommand: (program: Command, version: string) => void = generateClientUrl({ chainId: templateChainId, ...network, - networkExplorerUrl: network.networkExplorerUrl ?? '', }), ); diff --git a/packages/tools/kadena-cli/src/commands/tx/commands/txSend.ts b/packages/tools/kadena-cli/src/commands/tx/commands/txSend.ts index cba4f3d03e..33adbb87cd 100644 --- a/packages/tools/kadena-cli/src/commands/tx/commands/txSend.ts +++ b/packages/tools/kadena-cli/src/commands/tx/commands/txSend.ts @@ -20,12 +20,18 @@ import { log } from '../../../utils/logger.js'; import { txOptions } from '../txOptions.js'; import { parseTransactionsFromStdin } from '../utils/input.js'; import { displayTransactionResponse } from '../utils/txDisplayHelper.js'; -import type { INetworkDetails, ISubmitResponse } from '../utils/txHelpers.js'; +import type { + INetworkDetails, + ISubmitResponse, + IUpdateTransactionsLogPayload, +} from '../utils/txHelpers.js'; import { createTransactionWithDetails, getClient, getTransactionsFromFile, logTransactionDetails, + saveTransactionsToFile, + updateTransactionStatus, } from '../utils/txHelpers.js'; const clientInstances: Map = new Map(); @@ -66,19 +72,28 @@ export async function pollRequests( const results = await Promise.allSettled(pollingPromises); + const txLogsData: IUpdateTransactionsLogPayload[] = []; results.forEach((result) => { if (result.status === 'fulfilled') { const value = result.value; const { requestKey, status } = value; - if (status === 'success' && 'data' in value) { log.info(`Polling success for requestKey: ${requestKey}`); displayTransactionResponse(value.data[requestKey], 2); + txLogsData.push({ + requestKey, + status, + data: value.data[requestKey], + }); } else if (status === 'error' && 'error' in value) { log.error( `Polling error for requestKey: ${requestKey}, error:`, value.error, ); + txLogsData.push({ + requestKey, + status: 'failure', + }); } else if ('message' in value) { log.info( `Polling message for requestKey: ${requestKey}: ${value.message}`, @@ -88,6 +103,7 @@ export async function pollRequests( log.error(`Polling failed for a request, error:`, result.reason); } }); + await updateTransactionStatus(txLogsData); } export const sendTransactionAction = async ({ @@ -194,6 +210,7 @@ export const createSendTransactionCommand: ( ) .join('\n\n'), ); + await saveTransactionsToFile(result.data.transactions); } const txPoll = await option.poll(); diff --git a/packages/tools/kadena-cli/src/commands/tx/commands/txStatus.ts b/packages/tools/kadena-cli/src/commands/tx/commands/txStatus.ts index 7772ad2e89..8b9f111991 100644 --- a/packages/tools/kadena-cli/src/commands/tx/commands/txStatus.ts +++ b/packages/tools/kadena-cli/src/commands/tx/commands/txStatus.ts @@ -11,6 +11,7 @@ import { log } from '../../../utils/logger.js'; import { createTable } from '../../../utils/table.js'; import type { INetworkCreateOptions } from '../../networks/utils/networkHelpers.js'; import { txOptions } from '../txOptions.js'; +import { updateTransactionStatus } from '../utils/txHelpers.js'; export const getTxStatus = async ({ requestKey, @@ -56,6 +57,15 @@ export const getTxStatus = async ({ }; } + // To update the log file with the transaction status when requestKey is found + await updateTransactionStatus([ + { + requestKey, + status: result[trimmedRequestKey].result.status, + data: result[trimmedRequestKey], + }, + ]); + return { status: 'success', data: result[trimmedRequestKey], diff --git a/packages/tools/kadena-cli/src/commands/tx/index.ts b/packages/tools/kadena-cli/src/commands/tx/index.ts index ba9b185614..f186b92c06 100644 --- a/packages/tools/kadena-cli/src/commands/tx/index.ts +++ b/packages/tools/kadena-cli/src/commands/tx/index.ts @@ -1,4 +1,5 @@ import { createTransactionCommandNew } from './commands/txCreateTransaction.js'; +import { createTxHistoryCommand } from './commands/txHistory.js'; import { createTxListCommand } from './commands/txList.js'; import { createTxLocalCommand } from './commands/txLocal.js'; import { createSendTransactionCommand } from './commands/txSend.js'; @@ -22,4 +23,5 @@ export function txCommandFactory(program: Command, version: string): void { createTxStatusCommand(txProgram, version); createTxListCommand(txProgram, version); createTxLocalCommand(txProgram, version); + createTxHistoryCommand(txProgram, version); } diff --git a/packages/tools/kadena-cli/src/commands/tx/utils/txHelpers.ts b/packages/tools/kadena-cli/src/commands/tx/utils/txHelpers.ts index 167edde36c..354c7125f3 100644 --- a/packages/tools/kadena-cli/src/commands/tx/utils/txHelpers.ts +++ b/packages/tools/kadena-cli/src/commands/tx/utils/txHelpers.ts @@ -26,18 +26,24 @@ import type { INetworkCreateOptions, } from '../../networks/utils/networkHelpers.js'; +import jsYaml from 'js-yaml'; import path, { isAbsolute, join } from 'node:path'; import { z } from 'zod'; -import { TX_TEMPLATE_FOLDER } from '../../../constants/config.js'; +import { + TRANSACTIONS_LOG_FILE, + TRANSACTIONS_PATH, + TX_TEMPLATE_FOLDER, +} from '../../../constants/config.js'; import { ICommandSchema } from '../../../prompts/tx.js'; import { services } from '../../../services/index.js'; +import { KadenaError } from '../../../services/service-error.js'; import type { IWallet, IWalletKey, IWalletKeyPair, } from '../../../services/wallet/wallet.types.js'; import type { CommandResult } from '../../../utils/command.util.js'; -import { notEmpty } from '../../../utils/globalHelpers.js'; +import { isNotEmptyObject, notEmpty } from '../../../utils/globalHelpers.js'; import { log } from '../../../utils/logger.js'; import { createTable } from '../../../utils/table.js'; import type { ISavedTransaction } from './storage.js'; @@ -166,14 +172,16 @@ export async function getTransactions( * Formats the current date and time into a string with the format 'YYYY-MM-DD-HH:MM'. * @returns {string} Formatted date and time string. */ -export function formatDate(): string { - const now = new Date(); +export function formatDate(date?: Date): string { + const now = date ?? new Date(); + // @ts-expect-error + if (isNaN(now)) return 'N/A'; const year = now.getFullYear(); const month = (now.getMonth() + 1).toString().padStart(2, '0'); const day = now.getDate().toString().padStart(2, '0'); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); - return `${year}-${month}-${day}-${hours}:${minutes}`; + return `${year}-${month}-${day} ${hours}:${minutes}`; } export async function signTransactionWithWallet( @@ -709,7 +717,9 @@ function generateClientKey(details: INetworkDetails): string { * @param {INetworkDetails} details - The network details. * @returns {string} The client URL. */ -export function generateClientUrl(details: INetworkDetails): string { +export function generateClientUrl( + details: Pick, +): string { return `${details.networkHost}/chainweb/0.0/${details.networkId}/chain/${details.chainId}/pact`; } @@ -780,3 +790,141 @@ export const createTransactionWithDetails = async ( } return transactionsWithDetails; }; + +export const getTransactionDirectory = (): string | null => { + const kadenaDirectory = services.config.getDirectory(); + return notEmpty(kadenaDirectory) + ? path.join(kadenaDirectory, TRANSACTIONS_PATH) + : null; +}; + +export interface IUpdateTransactionsLogPayload { + requestKey: string; + status: 'success' | 'failure'; + data?: Partial; +} + +export interface ITransactionLogEntry + extends Partial> { + dateTime: string; + cmd: string; + chainId: ChainId; + networkId: string; + networkHost: string; + status?: 'success' | 'failure'; +} + +export interface ITransactionLog { + [requestKey: string]: ITransactionLogEntry; +} + +export const readTransactionLog = async ( + filePath: string, +): Promise => { + const fileContent = await services.filesystem.readFile(filePath); + return notEmpty(fileContent) + ? (jsYaml.load(fileContent) as ITransactionLog) + : null; +}; + +const writeTransactionLog = async ( + filePath: string, + data: ITransactionLog, +): Promise => { + try { + await services.filesystem.writeFile( + filePath, + jsYaml.dump(data, { lineWidth: -1 }), + ); + } catch (error) { + log.error(`Failed to write transaction log: ${error.message}`); + } +}; + +export const saveTransactionsToFile = async ( + transactions: ISubmitResponse[], +): Promise => { + try { + const transactionDir = getTransactionDirectory(); + if (!notEmpty(transactionDir)) throw new KadenaError('no_kadena_directory'); + + await services.filesystem.ensureDirectoryExists(transactionDir); + const transactionFilePath = path.join( + transactionDir, + TRANSACTIONS_LOG_FILE, + ); + + const currentTransactionLog = + (await readTransactionLog(transactionFilePath)) || {}; + + transactions.forEach( + ({ + requestKey, + transaction, + details: { networkId, networkHost, chainId }, + }) => { + currentTransactionLog[requestKey] = { + dateTime: new Date().toISOString(), + cmd: transaction.cmd, + networkId, + chainId, + networkHost, + }; + }, + ); + + await writeTransactionLog(transactionFilePath, currentTransactionLog); + } catch (error) { + log.error(`Failed to save transactions: ${error.message}`); + } +}; + +export const mergePayloadsWithTransactionLog = ( + transactionLog: ITransactionLog, + updatePayloads: IUpdateTransactionsLogPayload[], +): ITransactionLog => { + const updatedLog = { + ...transactionLog, + }; + updatePayloads.forEach(({ requestKey, status, data = {} }) => { + if (isNotEmptyObject(updatedLog[requestKey])) { + updatedLog[requestKey] = { + ...updatedLog[requestKey], + status, + txId: notEmpty(data.txId) ? data.txId : null, + }; + } else { + log.error(`No transaction found for request key: ${requestKey}`); + } + }); + + return updatedLog; +}; + +export const updateTransactionStatus = async ( + updatePayloads: IUpdateTransactionsLogPayload[], +): Promise => { + try { + const transactionDir = getTransactionDirectory(); + if (!notEmpty(transactionDir)) throw new KadenaError('no_kadena_directory'); + + const transactionFilePath = path.join( + transactionDir, + TRANSACTIONS_LOG_FILE, + ); + const currentTransactionLog = await readTransactionLog(transactionFilePath); + if (!currentTransactionLog) + throw new Error( + 'No transaction logs are available. Please ensure that transaction logs are present and try again.', + ); + + const updatedTransactionLog = mergePayloadsWithTransactionLog( + currentTransactionLog, + updatePayloads, + ); + + await writeTransactionLog(transactionFilePath, updatedTransactionLog); + } catch (error) { + log.error(`Failed to update transaction status: ${error.message}`); + } +}; diff --git a/packages/tools/kadena-cli/src/constants/config.ts b/packages/tools/kadena-cli/src/constants/config.ts index 7b831e5947..225b615b5f 100644 --- a/packages/tools/kadena-cli/src/constants/config.ts +++ b/packages/tools/kadena-cli/src/constants/config.ts @@ -28,6 +28,10 @@ export const ACCOUNT_DIR = 'accounts'; // Default settings path export const DEFAULT_SETTINGS_PATH = 'defaults'; +// transaction path +export const TRANSACTIONS_PATH = 'transactions'; +export const TRANSACTIONS_LOG_FILE = 'transactions-log.yaml'; + // key extensions export const WALLET_EXT = '.wallet'; export const WALLET_LEGACY_EXT = '.legacy.wallet';