UNPKG

@kadena/kadena-cli

Version:

Kadena CLI tool to interact with the Kadena blockchain (manage keys, transactions, etc.)

593 lines 25 kB
import { addSignatures, createClient, createSignWithKeypair, isSignedTransaction, } from '@kadena/client'; import { kadenaSignWithSeed } from '@kadena/hd-wallet'; import { kadenaSignFromRootKey as legacyKadenaSignWithSeed } from '@kadena/hd-wallet/chainweaver'; import { getExistingNetworks } from '../../../utils/helpers.js'; import { loadNetworkConfig } from '../../networks/utils/networkHelpers.js'; import jsYaml from 'js-yaml'; import path, { isAbsolute, join } from 'node:path'; import { z } from 'zod'; import { TRANSACTIONS_LOG_FILE, TRANSACTIONS_PATH, TX_TEMPLATE_FOLDER, } from '../../../constants/config.js'; import { IUnsignedCommandSchema } from '../../../prompts/tx.js'; import { services } from '../../../services/index.js'; import { isNotEmptyObject, loadUnknownFile, notEmpty, } from '../../../utils/globalHelpers.js'; import { log } from '../../../utils/logger.js'; import { createTable } from '../../../utils/table.js'; /** * * @param command - The command to check. * @returns True if the command is partially signed, false otherwise. */ export function isPartiallySignedTransaction(command) { return (command.sigs.some((sig) => sig === undefined || sig === null) && command.sigs.some((sig) => sig !== undefined && sig !== null)); } /** * * @param command - The command to check the signing status for. * @returns An array of objects, each containing a public key and a boolean indicating whether it has signed. */ export function getSignersStatus(command) { const parsedCmd = JSON.parse(command.cmd); return parsedCmd.signers.map((signer, index) => { var _a; return ({ publicKey: signer.pubKey, isSigned: ((_a = command.sigs[index]) === null || _a === void 0 ? void 0 : _a.sig) !== undefined, }); }); } export async function getAllTransactions(directory) { try { const files = await services.filesystem.readDir(directory); // Since naming convention is not enforced, we need to check the content of the files const transactionFiles = (await Promise.all(files.map(async (fileName) => { try { const content = await loadUnknownFile(path.join(directory, fileName)); const parsed = IUnsignedCommandSchema.safeParse(content); if (parsed.success) { const signed = isSignedTransaction(parsed.data); return { fileName, signed }; } return null; } catch (_error) { return null; } }))).filter(notEmpty); return transactionFiles; } catch (error) { log.error(`Error reading transaction directory: ${error}`); throw error; } } /** * Retrieves all transaction file names from the transaction directory based on the signature status. * @param {boolean} signed - Whether to retrieve signed or unsigned transactions. * @returns {Promise<string[]>} A promise that resolves to an array of transaction file names. * @throws Throws an error if reading the transaction directory fails. */ export async function getTransactions(signed, directory) { try { const transactionFiles = await getAllTransactions(directory); return transactionFiles .filter((tx) => tx.signed === signed) .map((tx) => tx.fileName); } catch (error) { log.error(`Error reading transaction directory: ${error}`); throw error; } } /** * 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(date) { const now = date !== null && date !== void 0 ? 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}`; } export async function signTransactionWithWallet(wallet, password, unsignedTransaction, relevantKeyPairs = []) { try { const signatures = await Promise.all(relevantKeyPairs.map(async (key) => { if (typeof key.index !== 'number') { throw new Error('Key index not found'); } if (wallet.legacy === true) { const sigUint8Array = await legacyKadenaSignWithSeed(password, unsignedTransaction.hash, wallet.seed, key.index); return { sig: Buffer.from(sigUint8Array).toString('hex'), pubKey: key.publicKey, }; } else { const signWithSeed = kadenaSignWithSeed(password, wallet.seed, key.index); const sigs = await signWithSeed(unsignedTransaction.hash); return { sig: sigs.sig, pubKey: key.publicKey, }; } })); return addSignatures(unsignedTransaction, ...signatures); } catch (error) { if (error.message === 'Decryption failed') { throw new Error('Incorrect password. Please verify the password and try again.'); } log.error(`Error signing transaction: ${error.message}`); return unsignedTransaction; } } /** * Signs a set of unsigned transactions using provided key pairs. * * @param keys - An array of key pairs used for signing transactions. * @param unsignedTransactions - An array of transactions that need to be signed. * @param legacy - Optional flag indicating whether to use legacy signing method. * @returns A promise that resolves to an array of either signed transactions, unchanged unsigned transactions, or undefined in case of errors. */ export async function signTransactionWithKeyPair(keys, unsignedTransactions) { try { const signedTransactions = []; for (let i = 0; i < unsignedTransactions.length; i++) { const unsignedCommand = unsignedTransactions[i]; const parsedTransaction = JSON.parse(unsignedCommand.cmd); const relevantKeyPairs = getRelevantKeypairs(parsedTransaction, keys); if (relevantKeyPairs.length === 0) { log.error(`\nNo matching signable keys found for transaction at index ${i} between wallet and transaction.\n`); continue; } const signWithKeypair = createSignWithKeypair(relevantKeyPairs); const command = await signWithKeypair(unsignedCommand); signedTransactions.push(command); } return signedTransactions; } catch (error) { throw new Error(`Error signing transaction: ${error.message}`); } } export function getRelevantKeypairs(tx, keypairs) { const relevantKeypairs = keypairs.filter((keypair) => tx.signers.some(({ pubKey }) => pubKey === keypair.publicKey)); return relevantKeypairs; } /** * retrieve transaction from file * * @param {string} transactionFile * @param {string} path * @param {boolean} signed * @returns {Promise<IUnsignedCommand | ICommand>} * @throws Will throw an error if the file cannot be read or the transaction cannot be processed. */ export async function getTransactionFromFile( /** absolute path, or relative to process.cwd() if starting with `.` */ transactionFile, signed) { try { const transactionFilePath = isAbsolute(transactionFile) ? transactionFile : join(process.cwd(), transactionFile); const fileContent = await services.filesystem.readFile(transactionFilePath); if (fileContent === null) { throw Error(`Failed to read file at path: ${transactionFilePath}`); } const transaction = JSON.parse(fileContent); const parsedTransaction = IUnsignedCommandSchema.parse(transaction); if (signed) { const isSignedTx = isSignedTransaction(parsedTransaction); if (!isSignedTx) { throw Error(`${transactionFile} is not a signed transaction`); } return parsedTransaction; } return parsedTransaction; // typecast because `IUnsignedCommand` uses undefined instead of null; } catch (error) { log.error(`Error processing ${signed ? 'signed' : 'unsigned'} transaction file: ${transactionFile}, failed with error: ${error}`); throw error; } } /** * Assesses the signing status of multiple transaction commands and returns a Promise with a response based on their states. * * @param commands - An array of command objects to assess. Each command can be a signed, partially signed, or undefined command. * @returns A Promise resolving to a CommandResult containing an array of ICommand objects. * @throws Error if the commands array is empty, indicating no commands were provided for assessment. */ export async function assessTransactionSigningStatus(commands) { if (commands.length === 0) { throw new Error('No commands provided.'); } let commandStatus = 'success'; const errors = []; const warnings = []; const signedCommands = []; const partiallySignedTransactions = []; for (const command of commands) { if (!command) { commandStatus = 'error'; errors.push('One or more transactions failed to sign.'); continue; } if (isSignedTransaction(command)) { if (commandStatus === 'error') { commandStatus = 'partial'; } signedCommands.push(command); } else if (isPartiallySignedTransaction(command)) { commandStatus = 'partial'; const status = getSignersStatus(command); const formattedStatus = status .map((signerStatus) => ` Public Key: ${signerStatus.publicKey}, Signed: ${signerStatus.isSigned ? 'Yes' : 'No'}`) .join('\n'); warnings.push(`Transaction with hash: ${command.hash} is partially signed:\n${formattedStatus}`); partiallySignedTransactions.push(`transaction-${command.hash.slice(0, 10)}-partial.json`); } else { errors.push(`Transaction with hash: ${command.hash} is skipped because no matching keys within wallet(s) were found and left unsigned.`); } } if (partiallySignedTransactions.length > 0) { const commandString = `\n kadena tx sign --tx-unsigned-transaction-files="${partiallySignedTransactions.join(',')}"`; warnings.push(`\n\nTo sign the partially signed transactions, now run the follow-up command:${commandString}`); } if (commandStatus === 'success' && errors.length === 0 && warnings.length === 0) { return { status: 'success', data: signedCommands, warnings }; } else if (commandStatus === 'error' && errors.length > 0) { return { status: 'error', errors, warnings }; } else { return { status: 'partial', data: signedCommands, errors, warnings }; } } export async function getTransactionsFromFile( /** absolute paths, or relative to process.cwd() if starting with `.` */ transactionFiles, signed) { const transactions = []; for (const transactionFileName of transactionFiles) { const transaction = await getTransactionFromFile(transactionFileName, signed); if (transaction !== undefined && transaction !== null) { transactions.push(transaction); } } return transactions; } export function parseInput(input) { if (Array.isArray(input)) { return input; } if (typeof input === 'string') { if (input.trim() === '') { return []; } return input.split(',').map((item) => item.trim()); } return []; } export function extractCommandData(command) { const payload = JSON.parse(command.cmd); const networkId = payload.networkId; const chainId = payload.meta.chainId; return { networkId, chainId }; } export const REQUEST_KEY_MAX_LENGTH = 44; export const REQUEST_KEY_MIN_LENGTH = 43; export const requestKeyValidation = z .string() .trim() .refine((val) => { if (val.length === REQUEST_KEY_MAX_LENGTH) { return val[val.length - 1] === '='; } return val.length === REQUEST_KEY_MIN_LENGTH; }, { message: 'Request key is invalid. Please provide a valid request key.', }); export async function getWalletsAndKeysForSigning(unsignedTransactions) { var _a; const wallets = await services.wallet.list(); const foundWalletsWithKeys = []; for (const wallet of wallets) { if (wallet !== null) { for (const command of unsignedTransactions) { try { const commandObj = JSON.parse(command.cmd); const signers = (_a = commandObj === null || commandObj === void 0 ? void 0 : commandObj.signers) !== null && _a !== void 0 ? _a : []; const { relevantKeyPairs } = await extractRelevantWalletAndKeyPairsFromCommand(command, wallet); const unsignedRelevantKeyPairs = relevantKeyPairs.filter((keyPair) => { const signerIndex = signers.findIndex((signer) => signer.pubKey === keyPair.publicKey); const sig = command.sigs[signerIndex]; // Check sig for this signer if null or undefined -> not signed return sig === null || sig === undefined; }); if (unsignedRelevantKeyPairs.length > 0) { const existingEntry = foundWalletsWithKeys.find((entry) => entry.wallet === wallet); if (existingEntry) { existingEntry.relevantKeyPairs = [ ...new Set([ ...existingEntry.relevantKeyPairs, ...unsignedRelevantKeyPairs, ]), ]; } else { foundWalletsWithKeys.push({ wallet: wallet, relevantKeyPairs: unsignedRelevantKeyPairs, }); } } } catch (error) { log.error(`Error processing wallet ${wallet} for command:`, error); } } } } return foundWalletsWithKeys; } export async function extractRelevantWalletAndKeyPairsFromCommand(command, wallet) { try { const parsedTransaction = JSON.parse(command.cmd); const relevantKeyPairs = getRelevantKeypairs(parsedTransaction, wallet.keys); return { wallet, relevantKeyPairs, }; } catch (error) { throw new Error('An error occurred while extracting key pairs.'); } } export async function filterRelevantUnsignedCommandsForWallet(unsignedCommands, walletWithKey) { const wallet = walletWithKey; if (!wallet) { log.error(`Wallet named '${wallet}' not found.`); return { unsignedCommands: [], skippedCommands: [...unsignedCommands], relevantKeyPairs: [], }; } const walletPublicKeys = wallet.relevantKeyPairs.map((keyPair) => keyPair.publicKey); const skippedCommands = []; const relevantUnsignedCommands = []; const relevantKeyPairs = []; unsignedCommands.forEach((command) => { const commandObj = JSON.parse(command.cmd); const signerPublicKeys = commandObj.signers.map((signer) => signer.pubKey); const isRelevant = signerPublicKeys.some((pubKey) => walletPublicKeys.includes(pubKey)); if (isRelevant) { relevantUnsignedCommands.push(command); signerPublicKeys.forEach((pubKey) => { const keyPair = wallet.relevantKeyPairs.find((kp) => kp.publicKey === pubKey); if (keyPair && !relevantKeyPairs.includes(keyPair)) { relevantKeyPairs.push(keyPair); } }); } else { skippedCommands.push(command); } }); return { unsignedCommands: relevantUnsignedCommands, skippedCommands, relevantKeyPairs, }; } export function processSigningStatus(savedTransactions, signingStatus) { if (signingStatus.status === 'success' || signingStatus.status === 'partial') { const commands = savedTransactions .filter((tx) => signingStatus.status === 'success' || tx.state === 'signed') .map((tx) => ({ command: tx.command, path: tx.filePath, })); if (signingStatus.status === 'partial') { return { status: 'partial', data: { commands }, errors: signingStatus.errors, warnings: signingStatus.warnings, }; } else { return { status: 'success', data: { commands }, warnings: signingStatus.warnings, }; } } else { return { status: 'error', errors: signingStatus.errors, warnings: signingStatus.warnings, }; } } export function displaySignersFromUnsignedCommands(unsignedCommands) { unsignedCommands.forEach((unsignedCommand, index) => { const command = JSON.parse(unsignedCommand.cmd); const table = createTable({ head: ['Public Key', 'Capabilities'] }); table.push(...command.signers.map((signer) => [ signer.pubKey, (signer.clist || []) .map((capability) => `${capability.name}(${capability.args.join(', ')})`) .join('\n'), ])); log.info(`Command ${index + 1} (hash: ${unsignedCommand.hash}) will now be signed with the following signers:`); log.info(table.toString()); }); } export async function logTransactionDetails(command) { var _a, _b, _c; const table = createTable({ head: ['Network ID', 'Chain ID'] }); try { const cmdPayload = JSON.parse(command.cmd); const networkId = (_a = cmdPayload.networkId) !== null && _a !== void 0 ? _a : 'N/A'; const chainId = (_b = cmdPayload.meta.chainId) !== null && _b !== void 0 ? _b : 'N/A'; const hash = (_c = command.hash) !== null && _c !== void 0 ? _c : 'N/A'; table.push([networkId, chainId]); if (table.length > 0) { log.info(log.color.green(`\nTransaction detail for command with hash: ${hash}`)); log.output(table.toString(), command); log.info('\n\n'); } else { log.info(`No transaction details to display for hash: ${hash}`); } } catch (error) { log.info(`No transaction details to display`); } } export const getTxTemplateDirectory = () => { const kadenaDir = services.config.getDirectory(); return path.join(kadenaDir, TX_TEMPLATE_FOLDER); }; /** * Generates a unique key for the client based on the network details. * @param {INetworkDetails} details - The network details. * @returns {string} The generated client key. */ function generateClientKey(details) { return `${details.networkHost}-${details.networkId}-${details.chainId}`; } /** * Creates a URL for the client based on the network details. * @param {INetworkDetails} details - The network details. * @returns {string} The client URL. */ export function generateClientUrl(details) { return `${details.networkHost}/chainweb/0.0/${details.networkId}/chain/${details.chainId}/pact`; } /** * Retrieves or creates a client instance based on network details. * @param {Map<string, IClient>} clientInstances - Map of client instances. * @param {INetworkDetails} details - The network details to identify the client. * @returns {IClient} The client instance. */ export function getClient(clientInstances, details) { const clientKey = generateClientKey(details); if (!clientInstances.has(clientKey)) { const client = createClient(generateClientUrl(details)); clientInstances.set(clientKey, client); } return clientInstances.get(clientKey); } export const createTransactionWithDetails = async (commands, networkForTransactions) => { const transactionsWithDetails = []; const existingNetworks = await getExistingNetworks(); for (let index = 0; index < commands.length; index++) { const command = commands[index]; const network = networkForTransactions.txTransactionNetwork[index]; if (!existingNetworks.some((item) => item.value === network)) { log.error(`Network "${network}" does not exist. Please create it using "kadena network create" command, the transaction "${index + 1}" with hash "${command.hash}" will not be sent.`); continue; } const networkDetails = await loadNetworkConfig(network); const commandData = extractCommandData(command); if (commandData.networkId === networkDetails.networkId) { transactionsWithDetails.push({ command, details: { chainId: commandData.chainId, ...networkDetails, }, }); } else { log.error(`Network ID: "${commandData.networkId}" in transaction command ${index + 1} does not match the Network ID: "${networkDetails.networkId}" from the provided network "${network}", transaction with hash "${command.hash}" will not be sent.`); } } return transactionsWithDetails; }; export const getTransactionDirectory = () => { const kadenaDirectory = services.config.getDirectory(); return path.join(kadenaDirectory, TRANSACTIONS_PATH); }; export const readTransactionLog = async (filePath) => { const fileContent = await services.filesystem.readFile(filePath); return notEmpty(fileContent) ? jsYaml.load(fileContent) : null; }; const writeTransactionLog = async (filePath, data) => { 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) => { try { const transactionDir = getTransactionDirectory(); 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, updatePayloads) => { 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) => { try { const transactionDir = getTransactionDirectory(); 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}`); } }; //# sourceMappingURL=txHelpers.js.map