UNPKG

@hashgraph/solo

Version:

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

910 lines 47.5 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); } }; import fs from 'node:fs'; import * as Base64 from 'js-base64'; import * as constants from './constants.js'; import { IGNORED_NODE_ACCOUNT_ID } from './constants.js'; import { AccountCreateTransaction, AccountId, AccountInfoQuery, AccountUpdateTransaction, Client, FileContentsQuery, FileId, Hbar, HbarUnit, KeyList, Logger, LogLevel, Long, PrivateKey, Status, TransferTransaction, } from '@hiero-ledger/sdk'; import { MissingArgumentError } from './errors/missing-argument-error.js'; import { ResourceNotFoundError } from './errors/resource-not-found-error.js'; import { SoloError } from './errors/solo-error.js'; import { Templates } from './templates.js'; import { entityId, sleep } from './helpers.js'; import { Duration } from './time/duration.js'; import { inject, injectable } from 'tsyringe-neo'; import { patchInject } from './dependency-injection/container-helper.js'; import { PodReference } from '../integration/kube/resources/pod/pod-reference.js'; import { SecretType } from '../integration/kube/resources/secret/secret-type.js'; import { InjectTokens } from './dependency-injection/inject-tokens.js'; import { SoloService } from './model/solo-service.js'; import { PathEx } from '../business/utils/path-ex.js'; import { NetworkNodeServicesBuilder } from './network-node-services-builder.js'; import { LocalConfigRuntimeState } from '../business/runtime-state/config/local/local-config-runtime-state.js'; import { Address } from '../business/address/address.js'; import { Numbers } from '../business/utils/numbers.js'; // TODO - revisit and remove once we complete the cutover to BN and no longer need MN to pull from CN. // This should remove this dependency on @hiero-ledger/proto import { proto } from '@hiero-ledger/proto'; import * as crypto from 'node:crypto'; const REASON_FAILED_TO_GET_KEYS = 'failed to get keys for accountId'; const REASON_SKIPPED = 'skipped since it does not have a genesis key'; const REASON_FAILED_TO_UPDATE_ACCOUNT = 'failed to update account keys'; const REASON_FAILED_TO_CREATE_K8S_S_KEY = 'failed to create k8s scrt key'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; let AccountManager = class AccountManager { logger; k8Factory; remoteConfig; localConfig; _portForwards; _forcePortForward = false; _nodeClient; constructor(logger, k8Factory, remoteConfig, localConfig) { this.logger = logger; this.k8Factory = k8Factory; this.remoteConfig = remoteConfig; this.localConfig = localConfig; this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name); this.remoteConfig = patchInject(remoteConfig, InjectTokens.RemoteConfigRuntimeState, this.constructor.name); this.localConfig = patchInject(localConfig, InjectTokens.LocalConfigRuntimeState, this.constructor.name); this._portForwards = []; this._nodeClient = null; } /** * Gets the account keys from the Kubernetes secret from which it is stored * @param accountId - the account ID for which we want its keys * @param namespace - the namespace storing the secret */ async getAccountKeysFromSecret(accountId, namespace) { const contexts = this.remoteConfig.getContexts(); for (const context of contexts) { try { const secrets = await this.k8Factory .getK8(context) .secrets() .list(namespace, [Templates.renderAccountKeySecretLabelSelector(accountId)]); if (secrets.length > 0) { const secret = secrets[0]; return { accountId: secret.labels['solo.hedera.com/account-id'], privateKey: Base64.decode(secret.data.privateKey), publicKey: Base64.decode(secret.data.publicKey), }; } } catch (error) { if (!(error instanceof ResourceNotFoundError)) { throw error; } } } // if it isn't in the secrets we can load genesis key return { accountId, privateKey: constants.GENESIS_KEY, publicKey: PrivateKey.fromStringED25519(constants.GENESIS_KEY).publicKey.toString(), }; } /** * Gets the treasury account private key from Kubernetes secret if it exists, else * returns the Genesis private key, then will return an AccountInfo object with the * accountId, ed25519PrivateKey, publicKey * @param namespace - the namespace that the secret is in * @param deploymentName */ async getTreasuryAccountKeys(namespace, deploymentName) { // check to see if the treasure account is in the secrets return await this.getAccountKeysFromSecret(this.getTreasuryAccountId(deploymentName).toString(), namespace); } /** * batch up the accounts into sets to be processed * @param [accountRange] * @returns an array of arrays of numbers representing the accounts to update */ batchAccounts(accountRange = constants.SYSTEM_ACCOUNTS) { const batchSize = constants.ACCOUNT_UPDATE_BATCH_SIZE; const batchSets = []; let currentBatch = []; for (const [start, end] of accountRange) { let batchCounter = start; for (let index = start; index <= end; index++) { currentBatch.push(index); batchCounter++; if (batchCounter % batchSize === 0) { batchSets.push(currentBatch); currentBatch = []; batchCounter = 0; } } } if (currentBatch.length > 0) { batchSets.push(currentBatch); } batchSets.push([constants.TREASURY_ACCOUNT]); return batchSets; } /** stops and closes the port forwards and the _nodeClient */ async close() { this._nodeClient?.close(); if (this._portForwards) { for (const srv of this._portForwards) { await this.k8Factory.default().pods().readByReference(null).stopPortForward(srv); } } this._nodeClient = null; this._portForwards = []; this.logger.debug('node client and port forwards have been closed'); } /** * loads and initializes the Node Client * @param namespace - the namespace of the network * @param clusterReferences - the cluster references * @param [deployment] - k8 deployment name * @param [forcePortForward] - whether to force the port forward */ async loadNodeClient(namespace, clusterReferences, deployment, forcePortForward) { try { this.logger.debug(`loading node client: [!this._nodeClient=${!this._nodeClient}, this._nodeClient.isClientShutDown=${this._nodeClient?.isClientShutDown}]`); if (!this._nodeClient || this._nodeClient?.isClientShutDown) { this.logger.debug(`refreshing node client: [!this._nodeClient=${!this._nodeClient}, this._nodeClient.isClientShutDown=${this._nodeClient?.isClientShutDown}]`); await this.refreshNodeClient(namespace, clusterReferences, undefined, deployment, forcePortForward); } else { try { if (!constants.SKIP_NODE_PING) { await this._nodeClient.ping(this._nodeClient.operatorAccountId); } } catch { this.logger.debug('node client ping failed, refreshing node client'); await this.refreshNodeClient(namespace, clusterReferences, undefined, deployment, forcePortForward); } } return this._nodeClient; } catch (error) { const message = `failed to load node client: ${error.message}`; throw new SoloError(message, error); } } /** * loads and initializes the Node Client, throws a SoloError if anything fails * @param namespace - the namespace of the network * @param clusterReferences - the cluster references * @param skipNodeAlias - the node alias to skip * @param deployment - the deployment name * @param [forcePortForward] - whether to force the port forward */ async refreshNodeClient(namespace, clusterReferences, skipNodeAlias, deployment, forcePortForward) { try { await this.close(); if (forcePortForward !== undefined) { this._forcePortForward = forcePortForward; } const treasuryAccountInfo = await this.getTreasuryAccountKeys(namespace, deployment); const networkNodeServicesMap = await this.getNodeServiceMap(namespace, clusterReferences, deployment); this._nodeClient = await this._getNodeClient(namespace, networkNodeServicesMap, treasuryAccountInfo.accountId, treasuryAccountInfo.privateKey, skipNodeAlias); this.logger.debug('node client has been refreshed'); return this._nodeClient; } catch (error) { const message = `failed to refresh node client: ${error.message}`; throw new SoloError(message, error); } } /** * if the load balancer IP is not set, then we should use the local host port forward * @param networkNodeServices * @returns whether to use the local host port forward */ shouldUseLocalHostPortForward(networkNodeServices) { return this._forcePortForward || !networkNodeServices.haProxyLoadBalancerIp; } /** * Returns a node client that can be used to make calls against * @param namespace - the namespace for which the node client resides * @param networkNodeServicesMap - a map of the service objects that proxy the nodes * @param operatorId - the account id of the operator of the transactions * @param operatorKey - the private key of the operator of the transactions * @param skipNodeAlias - the node alias to skip * @returns a node client that can be used to call transactions */ async _getNodeClient(namespace, networkNodeServicesMap, operatorId, operatorKey, skipNodeAlias) { let nodes = {}; const configureNodeAccessPromiseArray = []; try { let localPort = constants.LOCAL_NODE_START_PORT; for (const networkNodeService of networkNodeServicesMap.values()) { if (networkNodeService.accountId !== IGNORED_NODE_ACCOUNT_ID && networkNodeService.nodeAlias !== skipNodeAlias) { configureNodeAccessPromiseArray.push(this.configureNodeAccess(networkNodeService, localPort, networkNodeServicesMap.size)); localPort++; } } this.logger.debug(`configuring node access for ${configureNodeAccessPromiseArray.length} nodes`); await Promise.allSettled(configureNodeAccessPromiseArray).then((results) => { for (const result of results) { switch (result.status) { case REJECTED: { throw new SoloError(`failed to configure node access: ${result.reason}`); } case FULFILLED: { nodes = { ...nodes, ...result.value }; break; } } } }); this.logger.debug(`configured node access for ${Object.keys(nodes).length} nodes`); let formattedNetworkConnection = ''; for (const key of Object.keys(nodes)) { formattedNetworkConnection += `${key}:${nodes[key]}, `; } this.logger.info(`creating client from network configuration: [${formattedNetworkConnection}]`); // scheduleNetworkUpdate is set to false, because the ports 50212/50211 are hardcoded in JS SDK that will not work // when running locally or in a pipeline this._nodeClient = Client.fromConfig({ network: nodes, scheduleNetworkUpdate: false }); this._nodeClient.setOperator(operatorId, operatorKey); this._nodeClient.setLogger(new Logger(LogLevel.Trace, PathEx.join(constants.SOLO_LOGS_DIR, 'hashgraph-sdk.log'))); this._nodeClient.setMaxAttempts(constants.NODE_CLIENT_MAX_ATTEMPTS); this._nodeClient.setMinBackoff(constants.NODE_CLIENT_MIN_BACKOFF); this._nodeClient.setMaxBackoff(constants.NODE_CLIENT_MAX_BACKOFF); this._nodeClient.setRequestTimeout(constants.NODE_CLIENT_REQUEST_TIMEOUT); this._nodeClient.setMaxQueryPayment(new Hbar(constants.NODE_CLIENT_MAX_QUERY_PAYMENT)); // ping the node client to ensure it is working if (!constants.SKIP_NODE_PING) { await this._nodeClient.ping(AccountId.fromString(operatorId)); } return this._nodeClient; } catch (error) { throw new SoloError(`failed to setup node client: ${error.message}`, error); } } async configureNodeAccess(networkNodeService, localPort, totalNodes) { this.logger.debug(`configuring node access for node: ${networkNodeService.nodeAlias}`); const port = +networkNodeService.haProxyGrpcPort; const accountId = AccountId.fromString(networkNodeService.accountId); try { // if the load balancer IP is set, then we should use that and avoid the local host port forward if (!this.shouldUseLocalHostPortForward(networkNodeService)) { const host = networkNodeService.haProxyLoadBalancerIp; const endpoint = `${host}:${port}`; this.logger.debug(`using load balancer IP: ${endpoint}`); try { const object = { [endpoint]: accountId }; await this.sdkPingNetworkNode(object, accountId); this.logger.debug(`successfully pinged network node: ${endpoint}`); return object; } catch { // if the connection fails, then we should use the local host port forward } } // if the load balancer IP is not set or the test connection fails, then we should use the local host port forward const host = '127.0.0.1'; const endpoint = `${host}:${localPort}`; if (this._portForwards.length < totalNodes) { const portForward = await this.k8Factory .getK8(networkNodeService.context) .pods() .readByReference(PodReference.of(networkNodeService.namespace, networkNodeService.haProxyPodName)) .portForward(localPort, port); this._portForwards.push(portForward); this.logger.debug(`using local host port forward: ${host}:${portForward}`); } const object = { [endpoint]: accountId }; await this.testNodeClientConnection(object, accountId); return object; } catch (error) { throw new SoloError(`failed to configure node access: ${error.message}`, error); } } /** * pings the network node to ensure that the connection is working * @param object - the object containing the network node endpoint and account id * @param accountId - the account id to ping * @throws {@link SoloError} if the ping fails */ async testNodeClientConnection(object, accountId) { const maxRetries = constants.NODE_CLIENT_SDK_PING_MAX_RETRIES; const sleepInterval = constants.NODE_CLIENT_SDK_PING_RETRY_INTERVAL; let currentRetry = 0; let success = false; try { while (!success && currentRetry < maxRetries) { try { this.logger.debug(`attempting to sdk ping network node: ${Object.keys(object)[0]}, attempt: ${currentRetry}, of ${maxRetries}`); await this.sdkPingNetworkNode(object, accountId); success = true; return; } catch (error) { this.logger.error(`failed to sdk ping network node: ${Object.keys(object)[0]}, ${error.message}`); currentRetry++; await sleep(Duration.ofMillis(sleepInterval)); } } } catch (error) { const message = `failed testing node client connection for network node: ${Object.keys(object)[0]}, after ${maxRetries} retries: ${error.message}`; throw new SoloError(message, error); } if (currentRetry >= maxRetries) { throw new SoloError(`failed to sdk ping network node: ${Object.keys(object)[0]}, after ${maxRetries} retries`); } return; } /** * Gets a Map of the Hedera node services and the attributes needed, throws a SoloError if anything fails * @param namespace - the namespace of the solo network deployment * @param clusterReferences - the cluster references to use for the services * @param deployment - the deployment to use * @returns a map of the network node services */ async getNodeServiceMap(namespace, clusterReferences, deployment) { const labelSelector = 'solo.hedera.com/node-name'; const serviceBuilderMap = new Map(); try { const services = []; for (const [clusterReference, context] of clusterReferences) { const serviceList = await this.k8Factory.getK8(context).services().list(namespace, [labelSelector]); services.push(...serviceList.map((service) => SoloService.getFromK8Service(service, clusterReference, context, deployment))); } // retrieve the list of services and build custom objects for the attributes we need for (const service of services) { let nodeId; const clusterReference = service.clusterReference; let serviceBuilder = new NetworkNodeServicesBuilder(service.metadata.labels['solo.hedera.com/node-name']); if (serviceBuilderMap.has(serviceBuilder.key())) { serviceBuilder = serviceBuilderMap.get(serviceBuilder.key()); } else { serviceBuilder = new NetworkNodeServicesBuilder(service.metadata.labels['solo.hedera.com/node-name']); serviceBuilder.withNamespace(namespace); serviceBuilder.withClusterRef(clusterReference); serviceBuilder.withContext(clusterReferences.get(clusterReference)); serviceBuilder.withDeployment(deployment); } const serviceType = service.metadata.labels['solo.hedera.com/type']; switch (serviceType) { // solo.hedera.com/type: envoy-proxy-svc case 'envoy-proxy-svc': { serviceBuilder .withEnvoyProxyName(service.metadata.name) .withEnvoyProxyClusterIp(service.spec.clusterIP) .withEnvoyProxyLoadBalancerIp(service.status.loadBalancer.ingress ? service.status.loadBalancer.ingress[0].ip : undefined) .withEnvoyProxyGrpcWebPort(service.spec.ports.find((port) => port.name === 'hedera-grpc-web').port); break; } // solo.hedera.com/type: haproxy-svc case 'haproxy-svc': { serviceBuilder .withHaProxyAppSelector(service.spec.selector.app) .withHaProxyName(service.metadata.name) .withHaProxyClusterIp(service.spec.clusterIP) .withHaProxyLoadBalancerIp(service.status.loadBalancer.ingress ? service.status.loadBalancer.ingress[0].ip : undefined) .withHaProxyGrpcPort(service.spec.ports.find((port) => port.name === 'non-tls-grpc-client-port').port) .withHaProxyGrpcsPort(service.spec.ports.find((port) => port.name === 'tls-grpc-client-port').port); break; } // solo.hedera.com/type: network-node-svc case 'network-node-svc': { if (service.metadata.labels['solo.hedera.com/node-id'] !== '' && Numbers.isNumeric(service.metadata.labels['solo.hedera.com/node-id'])) { nodeId = +service.metadata.labels['solo.hedera.com/node-id']; } else { nodeId = +`${Templates.nodeIdFromNodeAlias(service.metadata.labels['solo.hedera.com/node-name'])}`; this.logger.warn(`received an incorrect node id of ${service.metadata.labels['solo.hedera.com/node-id']} for ` + `${service.metadata.labels['solo.hedera.com/node-name']}`); } serviceBuilder .withAccountId(service.metadata.labels['solo.hedera.com/account-id']) .withNodeServiceName(service.metadata.name) .withNodeServiceClusterIp(service.spec.clusterIP) .withNodeServiceLoadBalancerIp(service.status.loadBalancer.ingress ? service.status.loadBalancer.ingress[0].ip : undefined) .withNodeServiceGossipPort(service.spec.ports.find((port) => port.name === 'gossip').port) .withNodeServiceGrpcPort(service.spec.ports.find((port) => port.name === 'grpc-non-tls').port) .withNodeServiceGrpcsPort(service.spec.ports.find((port) => port.name === 'grpc-tls').port); if (typeof nodeId === 'number') { serviceBuilder.withNodeId(+nodeId); } break; } } const consensusNode = this.remoteConfig .getConsensusNodes() .find((node) => node.name === serviceBuilder.nodeAlias); const address = await Address.getExternalAddress(consensusNode, this.k8Factory.getK8(serviceBuilder.context), 0); serviceBuilder.withExternalAddress(address.hostString()); serviceBuilderMap.set(serviceBuilder.key(), serviceBuilder); } // get the pod name for the service to use with portForward if needed for (const serviceBuilder of serviceBuilderMap.values()) { const podList = await this.k8Factory .getK8(serviceBuilder.context) .pods() .list(namespace, [`app=${serviceBuilder.haProxyAppSelector}`]); serviceBuilder.withHaProxyPodName(podList[0].podReference.name); } for (const [_, context] of clusterReferences) { // get the pod name of the network node const pods = await this.k8Factory .getK8(context) .pods() .list(namespace, ['solo.hedera.com/type=network-node']); for (const pod of pods) { if (!pod.labels?.hasOwnProperty('solo.hedera.com/node-name')) { continue; } const podName = pod.podReference.name; const nodeAlias = pod.labels['solo.hedera.com/node-name']; const serviceBuilder = serviceBuilderMap.get(nodeAlias); serviceBuilder.withNodePodName(podName); } } const serviceMap = new Map(); for (const networkNodeServicesBuilder of serviceBuilderMap.values()) { serviceMap.set(networkNodeServicesBuilder.key(), networkNodeServicesBuilder.build()); } this.logger.debug('node services have been loaded'); return serviceMap; } catch (error) { throw new SoloError(`failed to get node services: ${error.message}`, error); } } /** * updates a set of special accounts keys with a newly generated key and stores them in a Kubernetes secret * @param namespace the namespace of the nodes network * @param currentSet - the accounts to update * @param updateSecrets - whether to delete the secret prior to creating a new secret * @param resultTracker - an object to keep track of the results from the accounts that are being updated * @param deploymentName - the deployment name * @returns the updated resultTracker object */ async updateSpecialAccountsKeys(namespace, currentSet, updateSecrets, resultTracker, deploymentName) { const genesisKey = PrivateKey.fromStringED25519(constants.OPERATOR_KEY); const accountUpdatePromiseArray = []; for (const accountNumber of currentSet) { accountUpdatePromiseArray.push(this.updateAccountKeys(namespace, this.getAccountIdByNumber(deploymentName, accountNumber), genesisKey, updateSecrets)); } await Promise.allSettled(accountUpdatePromiseArray).then((results) => { for (const result of results) { // @ts-expect-error - TS2339: to avoid type mismatch switch (result.value.status) { case REJECTED: { // @ts-expect-error - TS2339: to avoid type mismatch if (result.value.reason === REASON_SKIPPED) { resultTracker.skippedCount++; } else { // @ts-expect-error - TS2339: to avoid type mismatch this.logger.error(`REJECT: ${result.value.reason}: ${result.value.value}`); resultTracker.rejectedCount++; } break; } case FULFILLED: { resultTracker.fulfilledCount++; break; } } } }); this.logger.debug(`Current counts: [fulfilled: ${resultTracker.fulfilledCount}, ` + `skipped: ${resultTracker.skippedCount}, ` + `rejected: ${resultTracker.rejectedCount}]`); return resultTracker; } /** * update the account keys for a given account and store its new key in a Kubernetes secret * @param namespace - the namespace of the nodes network * @param accountId - the account that will get its keys updated * @param genesisKey - the genesis key to compare against * @param updateSecrets - whether to delete the secret before creating a new secret * @returns the result of the call */ async updateAccountKeys(namespace, accountId, genesisKey, updateSecrets) { let keys; try { keys = await this.getAccountKeys(accountId); } catch (error) { if (error instanceof MissingArgumentError) { throw error; } this.logger.error(`failed to get keys for accountId ${accountId.toString()}, e: ${error.toString()}\n ${error.stack}`); return { status: REJECTED, reason: REASON_FAILED_TO_GET_KEYS, value: accountId.toString(), }; } if (!keys || !keys[0]) { return { status: REJECTED, reason: REASON_FAILED_TO_GET_KEYS, value: accountId.toString(), }; } if (constants.GENESIS_PUBLIC_KEY.toString() !== keys[0].toString()) { this.logger.debug(`account ${accountId.toString()} can be skipped since it does not have a genesis key`); return { status: REJECTED, reason: REASON_SKIPPED, value: accountId.toString(), }; } this.logger.debug(`updating account ${accountId.toString()} since it is using the genesis key`); const newPrivateKey = PrivateKey.generateED25519(); try { await this.createOrReplaceAccountKeySecret(newPrivateKey, accountId, updateSecrets, namespace); } catch (error) { this.logger.error(error.message, error); return { status: REJECTED, reason: REASON_FAILED_TO_CREATE_K8S_S_KEY, value: accountId.toString(), }; } try { if (!(await this.sendAccountKeyUpdate(accountId, newPrivateKey, genesisKey))) { this.logger.error(`failed to update account keys for accountId ${accountId.toString()}`); return { status: REJECTED, reason: REASON_FAILED_TO_UPDATE_ACCOUNT, value: accountId.toString(), }; } } catch (error) { this.logger.error(`failed to update account keys for accountId ${accountId.toString()}, e: ${error.toString()}`); return { status: REJECTED, reason: REASON_FAILED_TO_UPDATE_ACCOUNT, value: accountId.toString(), }; } return { status: FULFILLED, value: accountId.toString(), }; } /** * creates or replaces the Kubernetes secret for the account key * @param privateKey - the private key to store in the secret * @param accountId - the account id for which to create the secret * @param updateSecrets - whether to replace the secret if it exists * @param namespace - the namespace in which to create the secret */ async createOrReplaceAccountKeySecret(privateKey, accountId, updateSecrets, namespace) { const data = { privateKey: Base64.encode(privateKey.toString()), publicKey: Base64.encode(privateKey.publicKey.toString()), }; try { const contexts = this.remoteConfig.getContexts(); for (const context of contexts) { const secretName = Templates.renderAccountKeySecretName(accountId); const secretLabels = Templates.renderAccountKeySecretLabelObject(accountId); const secretType = SecretType.OPAQUE; const createdOrUpdated = await (updateSecrets ? this.k8Factory.getK8(context).secrets().replace(namespace, secretName, secretType, data, secretLabels) : this.k8Factory.getK8(context).secrets().create(namespace, secretName, secretType, data, secretLabels)); if (!createdOrUpdated) { throw new SoloError(`failed to create secret for accountId ${accountId.toString()}`); } } } catch (error) { throw new SoloError(`failed to create secret for accountId ${accountId.toString()}, e: ${error.toString()}`, error); } } /** * gets the account info from Hedera network * @param accountId - the account * @returns the private key of the account */ async accountInfoQuery(accountId) { if (!this._nodeClient) { throw new MissingArgumentError('node client is not initialized'); } return await new AccountInfoQuery() .setAccountId(accountId) .setMaxAttempts(3) .setMaxBackoff(1000) .execute(this._nodeClient); } /** * gets the account private and public key from the Kubernetes secret from which it is stored * @param accountId - the account * @returns the private key of the account */ async getAccountKeys(accountId) { const accountInfo = await this.accountInfoQuery(accountId); let keys = []; if (accountInfo.key instanceof KeyList) { keys = accountInfo.key.toArray(); } else { keys.push(accountInfo.key); } return keys; } /** * send an account key update transaction to the network of nodes * @param accountId - the account that will get its keys updated * @param newPrivateKey - the new private key * @param oldPrivateKey - the genesis key that is the current key * @returns whether the update was successful */ async sendAccountKeyUpdate(accountId, newPrivateKey, oldPrivateKey) { if (typeof newPrivateKey === 'string') { newPrivateKey = PrivateKey.fromStringED25519(newPrivateKey); } if (typeof oldPrivateKey === 'string') { oldPrivateKey = PrivateKey.fromStringED25519(oldPrivateKey); } // Create the transaction to update the key on the account const transaction = new AccountUpdateTransaction() .setAccountId(accountId) .setKey(newPrivateKey.publicKey) .freezeWith(this._nodeClient); // Sign the transaction with the old key and new key let signedTransaction = await transaction.sign(oldPrivateKey); signedTransaction = await signedTransaction.sign(newPrivateKey); // SIgn the transaction with the client operator private key and submit to a Hedera network const txResponse = await signedTransaction.execute(this._nodeClient); // Request the receipt of the transaction const receipt = await txResponse.getReceipt(this._nodeClient); return receipt.status === Status.Success; } /** * creates a new Hedera account * @param namespace - the namespace to store the Kubernetes key secret into * @param privateKey - the private key of type PrivateKey * @param amount - the amount of HBAR to add to the account * @param [setAlias] - whether to set the alias of the account to the public key, requires the ed25519PrivateKey supplied to be ECDSA * @param context * @returns a custom object with the account information in it */ async createNewAccount(namespace, privateKey, amount, setAlias = false, context) { const newAccountTransaction = new AccountCreateTransaction() .setKey(privateKey) .setInitialBalance(Hbar.from(amount, HbarUnit.Hbar)); if (setAlias) { newAccountTransaction.setAlias(privateKey.publicKey.toEvmAddress()); } const newAccountResponse = await newAccountTransaction.execute(this._nodeClient); // Get the new account ID const transactionReceipt = await newAccountResponse.getReceipt(this._nodeClient); const accountInfo = { accountId: transactionReceipt.accountId.toString(), privateKey: privateKey.toString(), publicKey: privateKey.publicKey.toString(), balance: amount, }; // add the account alias if setAlias is true if (setAlias) { const accountId = accountInfo.accountId; const realm = transactionReceipt.accountId.realm; const shard = transactionReceipt.accountId.shard; const accountInfoQueryResult = await this.accountInfoQuery(accountId); accountInfo.accountAlias = entityId(shard, realm, accountInfoQueryResult.contractAccountId); } try { const accountSecretCreated = await this.k8Factory .getK8(context) .secrets() .createOrReplace(namespace, Templates.renderAccountKeySecretName(accountInfo.accountId), SecretType.OPAQUE, { privateKey: Base64.encode(accountInfo.privateKey), publicKey: Base64.encode(accountInfo.publicKey), }, Templates.renderAccountKeySecretLabelObject(accountInfo.accountId)); if (!accountSecretCreated) { this.logger.error(`new account created [accountId=${accountInfo.accountId}, amount=${amount} HBAR, publicKey=${accountInfo.publicKey}, privateKey=${accountInfo.privateKey}] but failed to create secret in Kubernetes`); throw new SoloError(`failed to create secret for accountId ${accountInfo.accountId.toString()}, keys were sent to log file`); } } catch (error) { if (error instanceof SoloError) { throw error; } throw new SoloError(`failed to create secret for accountId ${accountInfo.accountId.toString()}, e: ${error.toString()}`, error); } return accountInfo; } /** * transfer the specified amount of HBAR from one account to another * @param fromAccountId - the account to pull the HBAR from * @param toAccountId - the account to put the HBAR * @param hbarAmount - the amount of HBAR * @returns if the transaction was successfully posted */ async transferAmount(fromAccountId, toAccountId, hbarAmount) { try { const transaction = new TransferTransaction() .addHbarTransfer(fromAccountId, new Hbar(-1 * hbarAmount)) .addHbarTransfer(toAccountId, new Hbar(hbarAmount)) .freezeWith(this._nodeClient); const txResponse = await transaction.execute(this._nodeClient); const receipt = await txResponse.getReceipt(this._nodeClient); this.logger.debug(`The transfer from account ${fromAccountId} to account ${toAccountId} for amount ${hbarAmount} was ${receipt.status.toString()} `); return receipt.status === Status.Success; } catch (error) { throw new SoloError(`transfer amount failed with an error: ${error.toString()}`, error); } } /** * Fetch and prepare address book as a base64 string */ async prepareAddressBookBase64(namespace, clusterReferences, deployment, operatorId, operatorKey, forcePortForward) { // fetch AddressBook await this.loadNodeClient(namespace, clusterReferences, deployment, forcePortForward); const client = this._nodeClient; if (operatorId && operatorKey) { client.setOperator(operatorId, operatorKey); } const realm = this.localConfig.configuration.realmForDeployment(deployment); const shard = this.localConfig.configuration.shardForDeployment(deployment); const query = new FileContentsQuery().setFileId(new FileId(shard, realm, FileId.ADDRESS_BOOK.num)); return Buffer.from(await query.execute(client)).toString('base64'); } async getFileContents(namespace, fileNumber, clusterReferences, deployment, forcePortForward) { await this.loadNodeClient(namespace, clusterReferences, deployment, forcePortForward); const client = this._nodeClient; const realm = this.localConfig.configuration.realmForDeployment(deployment); const shard = this.localConfig.configuration.shardForDeployment(deployment); const fileId = FileId.fromString(entityId(shard, realm, fileNumber)); const queryFees = new FileContentsQuery().setFileId(fileId); return Buffer.from(await queryFees.execute(client)).toString('hex'); } /** * Build and prepare address book as a base64 string by reading gossip signing keys from the * local keys directory and node topology from RemoteConfig. * This method does not require Kubernetes services or secrets to exist yet, making it suitable * for use during simultaneous consensus node + mirror node deployment. * @param keysDirectory - path to the directory containing gossip key PEM files (e.g. ~/.solo/cache/keys) * @param deployment - deployment name, used to derive per-node account IDs */ async buildAddressBookBase64(keysDirectory, deployment) { const consensusNodes = this.remoteConfig.getConsensusNodes(); const nodeAliases = consensusNodes.map((node) => node.name); const accountMap = this.getNodeAccountMap(nodeAliases, deployment); const nodeAddresses = []; for (const consensusNode of consensusNodes) { const nodeAlias = consensusNode.name; const accountIdString = accountMap.get(nodeAlias); if (!accountIdString || accountIdString === IGNORED_NODE_ACCOUNT_ID) { continue; } const accountId = AccountId.fromString(accountIdString); // Use the pre-computed FQDN from ConsensusNode — always a cluster-internal domain name. const serviceEndpoint = { domainName: consensusNode.fullyQualifiedDomainName, port: constants.GRPC_PORT, }; // Read the gossip signing certificate from the local keys directory. // The mirror node importer uses the embedded public key to verify record file signatures. let rsaPubKeyHex; try { const pemFilePath = PathEx.join(keysDirectory, Templates.renderGossipPemPublicKeyFile(nodeAlias)); const pemData = fs.readFileSync(pemFilePath, 'utf8'); const cert = new crypto.X509Certificate(pemData); const derBuffer = cert.publicKey.export({ type: 'spki', format: 'der' }); rsaPubKeyHex = derBuffer.toString('hex'); } catch (error) { this.logger.warn(`Could not read gossip signing key for ${nodeAlias} from ${keysDirectory}: ${error.message}. ` + 'Address book entry will have no RSA_PubKey; mirror node importer may fail signature verification.'); } nodeAddresses.push({ nodeId: Long.fromNumber(consensusNode.nodeId), nodeAccountId: { shardNum: Long.fromNumber(Number(accountId.shard)), realmNum: Long.fromNumber(Number(accountId.realm)), accountNum: Long.fromNumber(Number(accountId.num)), }, RSA_PubKey: rsaPubKeyHex, serviceEndpoint: [serviceEndpoint], description: nodeAlias, }); } this.logger.debug(`Built local address book with ${nodeAddresses.length} nodes for deployment ${deployment}`); const addressBookBytes = proto.NodeAddressBook.encode({ nodeAddress: nodeAddresses }).finish(); return Buffer.from(addressBookBytes).toString('base64'); } /** * Pings the network node with a grpc call to ensure it is working, throws a SoloError if the ping fails * @param obj - the network node object where the key is the network endpoint and the value is the account id * @param accountId - the account id to ping * @throws {@link SoloError} if the ping fails */ async sdkPingNetworkNode(object, accountId) { let nodeClient; try { nodeClient = Client.fromConfig({ network: object, scheduleNetworkUpdate: false }); this.logger.debug(`sdk pinging network node: ${Object.keys(object)[0]}`); if (!constants.SKIP_NODE_PING) { await nodeClient.ping(accountId); } this.logger.debug(`sdk ping successful for network node: ${Object.keys(object)[0]}`); return; } catch (error) { throw new SoloError(`failed to sdk ping network node: ${Object.keys(object)[0]} ${error.message}`, error); } finally { if (nodeClient) { try { nodeClient.close(); } catch { // continue if nodeClient.close() fails } } } } getAccountIdByNumber(deployment, number) { const realm = this.localConfig.configuration.realmForDeployment(deployment); const shard = this.localConfig.configuration.shardForDeployment(deployment); return AccountId.fromString(entityId(shard, realm, number)); } getOperatorAccountId(deployment) { return this.getAccountIdByNumber(deployment, Number.parseInt(constants.DEFAULT_OPERATOR_ID_NUMBER.toString())); } getFreezeAccountId(deployment) { return this.getAccountIdByNumber(deployment, Number.parseInt(constants.DEFAULT_FREEZE_ID_NUMBER.toString())); } getTreasuryAccountId(deployment) { return this.getAccountIdByNumber(deployment, constants.DEFAULT_TREASURY_ID_NUMBER); } getStartAccountId(deployment) { return this.getAccountIdByNumber(deployment, Number.parseInt(constants.DEFAULT_START_ID_NUMBER.toString())); } /** * Create a map of node aliases to account IDs * @param nodeAliases * @param deploymentName * @returns the map of node IDs to account IDs */ getNodeAccountMap(nodeAliases, deploymentName) { const accountMap = new Map(); const realm = this.localConfig.configuration.realmForDeployment(deploymentName); const shard = this.localConfig.configuration.shardForDeployment(deploymentName); const firstAccountId = this.getStartAccountId(deploymentName); for (const nodeAlias of nodeAliases) { const nodeAccount = entityId(shard, realm, Long.fromNumber(Templates.nodeIdFromNodeAlias(nodeAlias)).add(firstAccountId.num)); accountMap.set(nodeAlias, nodeAccount); } return accountMap; } }; AccountManager = __decorate([ injectable(), __param(0, inject(InjectTokens.SoloLogger)), __param(1, inject(InjectTokens.K8Factory)), __param(2, inject(InjectTokens.RemoteConfigRuntimeState)), __param(3, inject(InjectTokens.LocalConfigRuntimeState)), __metadata("design:paramtypes", [Object, Object, Object, LocalConfigRuntimeState]) ], AccountManager); export { AccountManager }; //# sourceMappingURL=account-manager.js.map