UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

396 lines (393 loc) 21.2 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var FileCommand_1; import chalk from 'chalk'; import { BaseCommand } from './base.js'; import { SoloError } from '../core/errors/solo-error.js'; import { Flags as flags } from './flags.js'; import { Listr } from 'listr2'; import * as constants from '../core/constants.js'; import { FileCreateTransaction, FileUpdateTransaction, FileAppendTransaction, FileContentsQuery, FileInfoQuery, FileId, Status, PrivateKey, } from '@hiero-ledger/sdk'; import { inject, injectable } from 'tsyringe-neo'; import { InjectTokens } from '../core/dependency-injection/inject-tokens.js'; import { patchInject } from '../core/dependency-injection/container-helper.js'; import fs from 'node:fs'; import path from 'node:path'; let FileCommand = class FileCommand extends BaseCommand { static { FileCommand_1 = this; } accountManager; // Hiero's max content size per transaction static MAX_CHUNK_SIZE = 4096; constructor(accountManager) { super(); this.accountManager = accountManager; this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name); } static CREATE_FLAGS_LIST = { required: [flags.deployment, flags.filePath], optional: [], }; static UPDATE_FLAGS_LIST = { required: [flags.deployment, flags.fileId, flags.filePath], optional: [], }; async close() { await this.accountManager.close(); } /** * Helper method to prepare initial content and determine if append is needed * @param fileContent - The complete file content * @param operation - The operation being performed ('create' or 'update') * @returns Object with initialContent and needsAppend flag */ prepareInitialContent(fileContent, operation) { const needsAppend = fileContent.length > FileCommand_1.MAX_CHUNK_SIZE; let initialContent; if (needsAppend) { initialContent = fileContent.slice(0, FileCommand_1.MAX_CHUNK_SIZE); this.logger.showUser(chalk.gray(` ${operation === 'create' ? 'Creating' : 'Updating'} file with first ${initialContent.length} bytes (multi-part ${operation})...`)); } else { initialContent = fileContent; this.logger.showUser(chalk.gray(` ${operation === 'create' ? 'Creating' : 'Updating'} file with ${initialContent.length} bytes...`)); } return { initialContent, needsAppend }; } /** * Helper method to initialize configuration and read file content * @param argv - Command arguments * @param requireFileId - Whether file ID is required (true for update, false for create) * @returns Configuration context with file content */ async initializeFileConfig(argv, requireFileId) { // Load configurations await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); this.configManager.update(argv); const filePath = argv[flags.filePath.name]; const deployment = argv[flags.deployment.name]; const namespace = this.remoteConfig.getNamespace(); let fileId = ''; if (requireFileId) { fileId = argv[flags.fileId.name]; // Validate file ID format if (!/^\d+\.\d+\.\d+$/.test(fileId)) { throw new SoloError(`Invalid file ID format: ${fileId}. Expected format: 0.0.150`); } } // Validate file exists if (!fs.existsSync(filePath)) { throw new SoloError(`File not found: ${filePath}`); } // Read file content const fileContent = fs.readFileSync(filePath); const fileName = path.basename(filePath); this.logger.showUser(chalk.cyan(`File: ${fileName}`)); this.logger.showUser(chalk.cyan(`Size: ${fileContent.length} bytes`)); if (requireFileId) { this.logger.showUser(chalk.cyan(`File ID: ${fileId}`)); } const config = { fileId, filePath, deployment, namespace, }; return { config, fileContent, expectedSize: fileContent.length, }; } /** * Helper method to load node client and treasury keys * @param namespace - The namespace * @param deployment - The deployment name * @param useGenesisKeyForSystemFile - Whether to use genesis key for system file operations * @returns The private key to use for transactions */ async loadClientAndKeys(namespace, deployment, useGenesisKeyForSystemFile = false) { // Load node client await this.accountManager.loadNodeClient(namespace, this.remoteConfig.getClusterRefs(), deployment); // Use genesis key for system file operations if requested if (useGenesisKeyForSystemFile) { this.logger.showUser(chalk.cyan('Using genesis key for system file operations')); this.logger.showUser(chalk.gray(' Genesis key can be customized via GENESIS_KEY environment variable')); return PrivateKey.fromString(constants.GENESIS_KEY); } // Get treasury account keys const treasuryKeys = await this.accountManager.getTreasuryAccountKeys(namespace, deployment); return PrivateKey.fromString(treasuryKeys.privateKey); } /** * Helper method to verify uploaded file content * @param client - The Hiero client * @param fileId - The file ID to verify * @param expectedContent - The expected file content */ async verifyFileUpload(client, fileId, expectedContent) { const fileIdObject = FileId.fromString(fileId); this.logger.showUser(chalk.cyan('Querying file contents to verify upload...')); const fileContentsQuery = new FileContentsQuery().setFileId(fileIdObject); const retrievedContents = await fileContentsQuery.execute(client); const uploadedSize = retrievedContents.length; const expectedSize = expectedContent.length; this.logger.showUser(chalk.gray(` Expected size: ${expectedSize} bytes`)); this.logger.showUser(chalk.gray(` Retrieved size: ${uploadedSize} bytes`)); if (uploadedSize !== expectedSize) { // Check if this is a system file (0.0.101-0.0.200 range) const fileIdParts = fileId.split('.'); const fileNumber = Number.parseInt(fileIdParts[2]); const isSystemFile = fileNumber >= 101 && fileNumber <= 200; let errorMessage = `File size mismatch! Expected ${expectedSize} bytes but got ${uploadedSize} bytes`; if (isSystemFile && uploadedSize === 0) { errorMessage = `${errorMessage} ⚠️ System File Update Failed: File ${fileId} is a system file that appears to be immutable or requires special authorization. Possible reasons: 1. The genesis key may not have permission to update this specific system file 2. System file ${fileId} may require network-level authorization or freeze/unfreeze operations 3. The network may be using a different genesis key than expected Troubleshooting: • Verify the correct genesis key using: echo $GENESIS_KEY • Set custom genesis key: export GENESIS_KEY=<your-genesis-key> • Check if the file requires special permissions beyond genesis key • Consider using FileUpdateTransaction with additional authorization in custom code`; } throw new SoloError(errorMessage); } // Also verify content matches const contentsMatch = Buffer.from(retrievedContents).equals(Buffer.from(expectedContent)); if (!contentsMatch) { throw new SoloError('File content verification failed! Retrieved content does not match uploaded content'); } this.logger.showUser(chalk.green('✓ File verification successful')); this.logger.showUser(chalk.green(`✓ Size: ${uploadedSize} bytes`)); this.logger.showUser(chalk.green('✓ Content matches uploaded file')); } /** * Helper method to append remaining file chunks after initial create/update * @param task - The Listr task wrapper for updating progress * @param client - The Hiero client * @param fileId - The file ID to append to * @param fileContent - The complete file content * @param treasuryPrivateKey - The private key to sign transactions */ async appendFileChunks(task, client, fileId, fileContent, treasuryPrivateKey) { const fileIdObject = FileId.fromString(fileId); let offset = FileCommand_1.MAX_CHUNK_SIZE; let chunkIndex = 1; // Calculate total chunks needed const totalChunks = Math.ceil((fileContent.length - FileCommand_1.MAX_CHUNK_SIZE) / FileCommand_1.MAX_CHUNK_SIZE); while (offset < fileContent.length) { const chunk = fileContent.slice(offset, offset + FileCommand_1.MAX_CHUNK_SIZE); const remaining = fileContent.length - offset; // Update task title to show progress task.title = `Append remaining file content (chunk ${chunkIndex}/${totalChunks})`; this.logger.showUser(chalk.gray(` Appending chunk ${chunkIndex}/${totalChunks} (${chunk.length} bytes, ${remaining} bytes remaining)...`)); const fileAppendTx = new FileAppendTransaction() .setFileId(fileIdObject) .setContents(chunk) .setMaxTransactionFee(100) .freezeWith(client); const signedAppendTx = await fileAppendTx.sign(treasuryPrivateKey); const appendResponse = await signedAppendTx.execute(client); const appendReceipt = await appendResponse.getReceipt(client); if (appendReceipt.status !== Status.Success) { throw new SoloError(`File append (chunk ${chunkIndex}) failed with status: ${appendReceipt.status.toString()}`); } offset += FileCommand_1.MAX_CHUNK_SIZE; chunkIndex++; } // Update final title task.title = `Append remaining file content (${totalChunks} chunks completed)`; this.logger.showUser(chalk.green(`✓ Appended ${totalChunks} chunks successfully`)); } /** * Unified method to create or update a file on the Hiero network * @param argv - Command arguments * @param isCreate - True for create operation, false for update */ async executeFileOperation(argv, isCreate) { const tasks = new Listr([ { title: 'Initialize configuration', task: async (context_) => { const result = await this.initializeFileConfig(argv, !isCreate); context_.config = result.config; context_.fileContent = result.fileContent; context_.expectedSize = result.expectedSize; // Check if this is a system file (for update operations) if (!isCreate && context_.config.fileId) { const fileIdParts = context_.config.fileId.split('.'); const fileNumber = Number.parseInt(fileIdParts[2]); context_.isSystemFile = fileNumber >= 101 && fileNumber <= 200; } }, }, { title: 'Load node client and treasury keys', task: async (context_) => { const useGenesisKey = context_.isSystemFile || false; context_.treasuryPrivateKey = await this.loadClientAndKeys(context_.config.namespace, context_.config.deployment, useGenesisKey); }, }, { title: 'Check if file exists', skip: () => isCreate, // Skip for create operation task: async (context_) => { const client = this.accountManager._nodeClient; try { const fileIdObject = FileId.fromString(context_.config.fileId); const fileInfoQuery = new FileInfoQuery().setFileId(fileIdObject); const fileInfo = await fileInfoQuery.execute(client); context_.fileExists = true; this.logger.showUser(chalk.green(`File ${context_.config.fileId} exists. Proceeding with update.`)); this.logger.showUser(chalk.gray(` Current size: ${fileInfo.size.toString()} bytes`)); const keysCount = fileInfo.keys ? fileInfo.keys.toArray().length : 0; this.logger.showUser(chalk.gray(` Keys: ${keysCount}`)); // Check if file is a system file (no keys = immutable) if (keysCount === 0) { if (context_.isSystemFile) { this.logger.showUser(chalk.cyan(`ℹ️ File ${context_.config.fileId} is a system file (no keys). Automatically using genesis key for update.`)); } else { this.logger.showUser(chalk.yellow(`⚠️ Warning: File ${context_.config.fileId} has no keys but is not in system file range (0.0.101-0.0.200).`)); this.logger.showUser(chalk.yellow(' Update may fail. Set GENESIS_KEY environment variable if this file requires genesis key authorization.')); } } } catch (error) { const error_ = error.status === Status.FileDeleted || error.status === Status.InvalidFileId ? new SoloError(`File ${context_.config.fileId} does not exist. Use 'ledger file create' to create a new file.`) : new SoloError(`Failed to query file info: ${error.message}`, error); throw error_; } }, }, { title: isCreate ? 'Create file on Hiero network' : 'Update file on Hiero network', task: async (context_, task) => { const client = this.accountManager._nodeClient; const subTasks = [ { title: isCreate ? 'Create new file' : 'Update existing file', task: async (context__, task) => { const { initialContent, needsAppend } = this.prepareInitialContent(context__.fileContent, isCreate ? 'create' : 'update'); if (isCreate) { // Create new file const fileCreateTx = new FileCreateTransaction() .setKeys([context__.treasuryPrivateKey.publicKey]) .setContents(initialContent) .setMaxTransactionFee(100) .freezeWith(client); const signedTx = await fileCreateTx.sign(context__.treasuryPrivateKey); const txResponse = await signedTx.execute(client); const receipt = await txResponse.getReceipt(client); if (receipt.status !== Status.Success) { throw new SoloError(`File creation failed with status: ${receipt.status.toString()}`); } const createdFileId = receipt.fileId; context__.createdFileId = createdFileId?.toString(); context__.config.fileId = context__.createdFileId; // Update config with actual file ID this.logger.showUser(chalk.green(`✓ File created with ID: ${context__.createdFileId}`)); } else { // Update existing file const fileIdObject = FileId.fromString(context__.config.fileId); const fileUpdateTx = new FileUpdateTransaction() .setFileId(fileIdObject) .setContents(initialContent) .setMaxTransactionFee(100) .freezeWith(client); const signedUpdateTx = await fileUpdateTx.sign(context__.treasuryPrivateKey); const updateResponse = await signedUpdateTx.execute(client); const updateReceipt = await updateResponse.getReceipt(client); if (updateReceipt.status !== Status.Success) { throw new SoloError(`File update failed with status: ${updateReceipt.status.toString()}`); } this.logger.showUser(chalk.green('✓ File updated successfully')); } // Append remaining content if needed if (needsAppend) { const appendSubtasks = [ { title: 'Append remaining file content', task: async (context__, appendTask) => { await this.appendFileChunks(appendTask, client, context__.config.fileId, context__.fileContent, context__.treasuryPrivateKey); }, }, ]; return task.newListr(appendSubtasks, { concurrent: false, rendererOptions: { collapseSubtasks: false }, }); } }, }, ]; // Create or update file return task.newListr(subTasks, { concurrent: false, rendererOptions: { collapseSubtasks: false, }, }); }, }, { title: 'Verify uploaded file', task: async (context_) => { const client = this.accountManager._nodeClient; await this.verifyFileUpload(client, context_.config.fileId, context_.fileContent); }, }, ], constants.LISTR_DEFAULT_OPTIONS.DEFAULT); try { await tasks.run(); const context = tasks.ctx; if (isCreate) { this.logger.showUser(chalk.green('\n✅ File created successfully!')); this.logger.showUser(chalk.cyan(`📄 File ID: ${context.createdFileId}`)); } else { this.logger.showUser(chalk.green('\n✅ File updated successfully!')); } } catch (error) { const operation = isCreate ? 'creation' : 'update'; throw new SoloError(`File ${operation} failed: ${error.message}`, error); } return true; } /** * Create a new file on the Hiero network */ async create(argv) { return this.executeFileOperation(argv, true); } /** * Update an existing file on the Hiero network */ async update(argv) { return this.executeFileOperation(argv, false); } }; FileCommand = FileCommand_1 = __decorate([ injectable(), __param(0, inject(InjectTokens.AccountManager)), __metadata("design:paramtypes", [Function]) ], FileCommand); export { FileCommand }; //# sourceMappingURL=file.js.map