UNPKG

@hashgraph/solo

Version:

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

528 lines (459 loc) 20.7 kB
// SPDX-License-Identifier: Apache-2.0 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 {type AccountManager} from '../core/account-manager.js'; import { FileCreateTransaction, FileUpdateTransaction, FileAppendTransaction, FileContentsQuery, FileInfoQuery, FileId, Status, PrivateKey, } from '@hiero-ledger/sdk'; import {type ArgvStruct} from '../types/aliases.js'; 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 {type CommandFlags} from '../types/flag-types.js'; import {type DeploymentName, type SoloListr, type SoloListrTask} from '../types/index.js'; import {type NamespaceName} from '../types/namespace/namespace-name.js'; import fs from 'node:fs'; import path from 'node:path'; interface FileUploadConfig { fileId: string; filePath: string; deployment: DeploymentName; namespace: NamespaceName; } interface FileUploadContext { config: FileUploadConfig; fileExists: boolean; fileContent: Uint8Array; uploadedSize: number; expectedSize: number; treasuryPrivateKey: PrivateKey; } @injectable() export class FileCommand extends BaseCommand { // Hiero's max content size per transaction private static readonly MAX_CHUNK_SIZE = 4096; public constructor(@inject(InjectTokens.AccountManager) private readonly accountManager: AccountManager) { super(); this.accountManager = patchInject(accountManager, InjectTokens.AccountManager, this.constructor.name); } public static CREATE_FLAGS_LIST: CommandFlags = { required: [flags.deployment, flags.filePath], optional: [], }; public static UPDATE_FLAGS_LIST: CommandFlags = { required: [flags.deployment, flags.fileId, flags.filePath], optional: [], }; public async close(): Promise<void> { 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 */ private prepareInitialContent( fileContent: Uint8Array, operation: 'create' | 'update', ): {initialContent: Uint8Array; needsAppend: boolean} { const needsAppend: boolean = fileContent.length > FileCommand.MAX_CHUNK_SIZE; let initialContent: Uint8Array; if (needsAppend) { initialContent = fileContent.slice(0, FileCommand.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 */ private async initializeFileConfig( argv: ArgvStruct, requireFileId: boolean, ): Promise<{ config: FileUploadConfig; fileContent: Uint8Array; expectedSize: number; }> { // Load configurations await this.localConfig.load(); await this.remoteConfig.loadAndValidate(argv); this.configManager.update(argv); const filePath: string = argv[flags.filePath.name] as string; const deployment: DeploymentName = argv[flags.deployment.name] as DeploymentName; const namespace: NamespaceName = this.remoteConfig.getNamespace(); let fileId: string = ''; if (requireFileId) { fileId = argv[flags.fileId.name] as string; // 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: Buffer = fs.readFileSync(filePath); const fileName: string = 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: FileUploadConfig = { 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 */ private async loadClientAndKeys( namespace: NamespaceName, deployment: DeploymentName, useGenesisKeyForSystemFile: boolean = false, ): Promise<PrivateKey> { // 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 */ private async verifyFileUpload(client: any, fileId: string, expectedContent: Uint8Array): Promise<void> { const fileIdObject: FileId = FileId.fromString(fileId); this.logger.showUser(chalk.cyan('Querying file contents to verify upload...')); const fileContentsQuery: FileContentsQuery = new FileContentsQuery().setFileId(fileIdObject); const retrievedContents: Uint8Array = await fileContentsQuery.execute(client); const uploadedSize: number = retrievedContents.length; const expectedSize: number = 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: string[] = fileId.split('.'); const fileNumber: number = Number.parseInt(fileIdParts[2]); const isSystemFile: boolean = fileNumber >= 101 && fileNumber <= 200; let errorMessage: string = `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: boolean = 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 */ private async appendFileChunks( task: any, client: any, fileId: string, fileContent: Uint8Array, treasuryPrivateKey: PrivateKey, ): Promise<void> { const fileIdObject: FileId = FileId.fromString(fileId); let offset: number = FileCommand.MAX_CHUNK_SIZE; let chunkIndex: number = 1; // Calculate total chunks needed const totalChunks: number = Math.ceil( (fileContent.length - FileCommand.MAX_CHUNK_SIZE) / FileCommand.MAX_CHUNK_SIZE, ); while (offset < fileContent.length) { const chunk: Uint8Array = fileContent.slice(offset, offset + FileCommand.MAX_CHUNK_SIZE); const remaining: number = 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: FileAppendTransaction = new FileAppendTransaction() .setFileId(fileIdObject) .setContents(chunk) .setMaxTransactionFee(100) .freezeWith(client); const signedAppendTx: FileAppendTransaction = await fileAppendTx.sign(treasuryPrivateKey); const appendResponse: any = await signedAppendTx.execute(client); const appendReceipt: any = await appendResponse.getReceipt(client); if (appendReceipt.status !== Status.Success) { throw new SoloError(`File append (chunk ${chunkIndex}) failed with status: ${appendReceipt.status.toString()}`); } offset += FileCommand.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 */ private async executeFileOperation(argv: ArgvStruct, isCreate: boolean): Promise<boolean> { interface Context extends FileUploadContext { createdFileId?: string; isSystemFile?: boolean; } const tasks = new Listr<Context>( [ { title: 'Initialize configuration', task: async context_ => { const result: {config: FileUploadConfig; fileContent: Uint8Array; expectedSize: number} = 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: string[] = context_.config.fileId.split('.'); const fileNumber: number = Number.parseInt(fileIdParts[2]); context_.isSystemFile = fileNumber >= 101 && fileNumber <= 200; } }, }, { title: 'Load node client and treasury keys', task: async context_ => { const useGenesisKey: boolean = 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: any = this.accountManager._nodeClient!; try { const fileIdObject: FileId = FileId.fromString(context_.config.fileId); const fileInfoQuery: FileInfoQuery = new FileInfoQuery().setFileId(fileIdObject); const fileInfo: any = 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: number = 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: any) { 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): Promise<SoloListr<Context>> => { const client: any = this.accountManager._nodeClient!; const subTasks: SoloListrTask<Context>[] = [ { title: isCreate ? 'Create new file' : 'Update existing file', task: async (context__, task): Promise<SoloListr<Context> | void> => { const {initialContent, needsAppend}: {initialContent: Uint8Array; needsAppend: boolean} = this.prepareInitialContent(context__.fileContent, isCreate ? 'create' : 'update'); if (isCreate) { // Create new file const fileCreateTx: FileCreateTransaction = new FileCreateTransaction() .setKeys([context__.treasuryPrivateKey.publicKey]) .setContents(initialContent) .setMaxTransactionFee(100) .freezeWith(client); const signedTx: FileCreateTransaction = await fileCreateTx.sign(context__.treasuryPrivateKey); const txResponse: any = await signedTx.execute(client); const receipt: any = await txResponse.getReceipt(client); if (receipt.status !== Status.Success) { throw new SoloError(`File creation failed with status: ${receipt.status.toString()}`); } const createdFileId: FileId | null = 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 = FileId.fromString(context__.config.fileId); const fileUpdateTx: FileUpdateTransaction = new FileUpdateTransaction() .setFileId(fileIdObject) .setContents(initialContent) .setMaxTransactionFee(100) .freezeWith(client); const signedUpdateTx: FileUpdateTransaction = await fileUpdateTx.sign(context__.treasuryPrivateKey); const updateResponse: any = await signedUpdateTx.execute(client); const updateReceipt: any = 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: SoloListrTask<Context>[] = [ { 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: Context = tasks.ctx as Context; 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: any) { const operation: string = isCreate ? 'creation' : 'update'; throw new SoloError(`File ${operation} failed: ${error.message}`, error); } return true; } /** * Create a new file on the Hiero network */ public async create(argv: ArgvStruct): Promise<boolean> { return this.executeFileOperation(argv, true); } /** * Update an existing file on the Hiero network */ public async update(argv: ArgvStruct): Promise<boolean> { return this.executeFileOperation(argv, false); } }