@kadena/kadena-cli
Version:
Kadena CLI tool to interact with the Kadena blockchain (manage keys, transactions, etc.)
593 lines • 25 kB
JavaScript
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