@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
528 lines (459 loc) • 20.7 kB
text/typescript
// 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;
}
()
export class FileCommand extends BaseCommand {
// Hiero's max content size per transaction
private static readonly MAX_CHUNK_SIZE = 4096;
public constructor((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);
}
}