@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
731 lines (658 loc) • 26.7 kB
text/typescript
/**
* SPDX-License-Identifier: Apache-2.0
*/
import chalk from 'chalk';
import {BaseCommand, type Opts} from './base.js';
import {IllegalArgumentError, SoloError} from '../core/errors.js';
import {Flags as flags} from './flags.js';
import {Listr} from 'listr2';
import * as constants from '../core/constants.js';
import {FREEZE_ADMIN_ACCOUNT} from '../core/constants.js';
import * as helpers from '../core/helpers.js';
import {sleep} from '../core/helpers.js';
import {type AccountManager} from '../core/account_manager.js';
import {type AccountId, AccountInfo, HbarUnit, NodeUpdateTransaction, PrivateKey} from '@hashgraph/sdk';
import {ListrLease} from '../core/lease/listr_lease.js';
import {type CommandBuilder, type NodeAliases} from '../types/aliases.js';
import {resolveNamespaceFromDeployment} from '../core/resolvers.js';
import {Duration} from '../core/time/duration.js';
import {type NamespaceName} from '../core/kube/resources/namespace/namespace_name.js';
import {type DeploymentName} from '../core/config/remote/types.js';
import {Templates} from '../core/templates.js';
import {SecretType} from '../core/kube/resources/secret/secret_type.js';
import {Base64} from 'js-base64';
export class AccountCommand extends BaseCommand {
private readonly accountManager: AccountManager;
private accountInfo: {
accountId: string;
balance: number;
publicKey: string;
privateKey?: string;
accountAlias?: string;
} | null;
private readonly systemAccounts: number[][];
constructor(opts: Opts, systemAccounts = constants.SYSTEM_ACCOUNTS) {
super(opts);
if (!opts || !opts.accountManager)
throw new IllegalArgumentError('An instance of core/AccountManager is required', opts.accountManager as any);
this.accountManager = opts.accountManager;
this.accountInfo = null;
this.systemAccounts = systemAccounts;
}
async closeConnections() {
await this.accountManager.close();
}
async buildAccountInfo(accountInfo: AccountInfo, namespace: NamespaceName, shouldRetrievePrivateKey: boolean) {
if (!accountInfo || !(accountInfo instanceof AccountInfo))
throw new IllegalArgumentError('An instance of AccountInfo is required');
const newAccountInfo: {
accountId: string;
balance: number;
publicKey: string;
privateKey?: string;
privateKeyRaw?: string;
} = {
accountId: accountInfo.accountId.toString(),
publicKey: accountInfo.key.toString(),
balance: accountInfo.balance.to(HbarUnit.Hbar).toNumber(),
};
if (shouldRetrievePrivateKey) {
const accountKeys = await this.accountManager.getAccountKeysFromSecret(newAccountInfo.accountId, namespace);
newAccountInfo.privateKey = accountKeys.privateKey;
// reconstruct private key to retrieve EVM address if private key is ECDSA type
try {
const privateKey = PrivateKey.fromStringDer(newAccountInfo.privateKey);
newAccountInfo.privateKeyRaw = privateKey.toStringRaw();
} catch (e: Error | any) {
this.logger.error(`failed to retrieve EVM address for accountId ${newAccountInfo.accountId}`);
}
}
return newAccountInfo;
}
async createNewAccount(ctx: {
config: {
generateEcdsaKey: boolean;
ecdsaPrivateKey?: string;
ed25519PrivateKey?: string;
namespace: NamespaceName;
setAlias: boolean;
amount: number;
};
privateKey: PrivateKey;
}) {
if (ctx.config.ecdsaPrivateKey) {
ctx.privateKey = PrivateKey.fromStringECDSA(ctx.config.ecdsaPrivateKey);
} else if (ctx.config.ed25519PrivateKey) {
ctx.privateKey = PrivateKey.fromStringED25519(ctx.config.ed25519PrivateKey);
} else if (ctx.config.generateEcdsaKey) {
ctx.privateKey = PrivateKey.generateECDSA();
} else {
ctx.privateKey = PrivateKey.generateED25519();
}
return await this.accountManager.createNewAccount(
ctx.config.namespace,
ctx.privateKey,
ctx.config.amount,
ctx.config.ecdsaPrivateKey || ctx.config.generateEcdsaKey ? ctx.config.setAlias : false,
);
}
getAccountInfo(ctx: {config: {accountId: string}}) {
return this.accountManager.accountInfoQuery(ctx.config.accountId);
}
async updateAccountInfo(ctx: any) {
let amount = ctx.config.amount;
if (ctx.config.ed25519PrivateKey) {
if (
!(await this.accountManager.sendAccountKeyUpdate(
ctx.accountInfo.accountId,
ctx.config.ed25519PrivateKey,
ctx.accountInfo.privateKey,
))
) {
this.logger.error(`failed to update account keys for accountId ${ctx.accountInfo.accountId}`);
return false;
}
} else {
amount = amount || flags.amount.definition.defaultValue;
}
const hbarAmount = Number.parseFloat(amount);
if (amount && isNaN(hbarAmount)) {
throw new SoloError(`The HBAR amount was invalid: ${amount}`);
}
if (hbarAmount > 0) {
if (!(await this.transferAmountFromOperator(ctx.accountInfo.accountId, hbarAmount))) {
this.logger.error(`failed to transfer amount for accountId ${ctx.accountInfo.accountId}`);
return false;
}
this.logger.debug(`sent transfer amount for account ${ctx.accountInfo.accountId}`);
}
return true;
}
async transferAmountFromOperator(toAccountId: AccountId, amount: number) {
return await this.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, toAccountId, amount);
}
async init(argv: any) {
const self = this;
interface Context {
config: {
namespace: NamespaceName;
nodeAliases: NodeAliases;
};
updateSecrets: boolean;
accountsBatchedSet: number[][];
resultTracker: {
rejectedCount: number;
fulfilledCount: number;
skippedCount: number;
};
}
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
self.configManager.update(argv);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
if (!(await this.k8Factory.default().namespaces().has(namespace))) {
throw new SoloError(`namespace ${namespace.name} does not exist`);
}
ctx.config = {
namespace: namespace,
nodeAliases: helpers.parseNodeAliases(this.configManager.getFlag(flags.nodeAliasesUnparsed)),
};
self.logger.debug('Initialized config', ctx.config);
await self.accountManager.loadNodeClient(
ctx.config.namespace,
self.getClusterRefs(),
self.configManager.getFlag<DeploymentName>(flags.deployment),
self.configManager.getFlag<boolean>(flags.forcePortForward),
);
},
},
{
title: 'Update special account keys',
task: (_, task) => {
return new Listr(
[
{
title: 'Prepare for account key updates',
task: async ctx => {
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const secrets = await self.k8Factory
.default()
.secrets()
.list(namespace, ['solo.hedera.com/account-id']);
ctx.updateSecrets = secrets.length > 0;
ctx.accountsBatchedSet = self.accountManager.batchAccounts(this.systemAccounts);
ctx.resultTracker = {
rejectedCount: 0,
fulfilledCount: 0,
skippedCount: 0,
};
// do a write transaction to trigger the handler and generate the system accounts to complete genesis
await self.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, FREEZE_ADMIN_ACCOUNT, 1);
},
},
{
title: 'Update special account key sets',
task: ctx => {
const subTasks: any[] = [];
const realm = constants.HEDERA_NODE_ACCOUNT_ID_START.realm;
const shard = constants.HEDERA_NODE_ACCOUNT_ID_START.shard;
for (const currentSet of ctx.accountsBatchedSet) {
const accStart = `${realm}.${shard}.${currentSet[0]}`;
const accEnd = `${realm}.${shard}.${currentSet[currentSet.length - 1]}`;
const rangeStr =
accStart !== accEnd
? `${chalk.yellow(accStart)} to ${chalk.yellow(accEnd)}`
: `${chalk.yellow(accStart)}`;
subTasks.push({
title: `Updating accounts [${rangeStr}]`,
task: async (ctx: Context) => {
ctx.resultTracker = await self.accountManager.updateSpecialAccountsKeys(
ctx.config.namespace,
currentSet,
ctx.updateSecrets,
ctx.resultTracker,
);
},
});
}
// set up the sub-tasks
return task.newListr(subTasks, {
concurrent: false,
rendererOptions: {
collapseSubtasks: false,
},
});
},
},
{
title: 'Update node admin key',
task: async ctx => {
const adminKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY);
for (const nodeAlias of ctx.config.nodeAliases) {
const nodeId = Templates.nodeIdFromNodeAlias(nodeAlias);
const nodeClient = await self.accountManager.refreshNodeClient(
ctx.config.namespace,
nodeAlias,
self.getClusterRefs(),
this.configManager.getFlag<DeploymentName>(flags.deployment),
);
try {
let nodeUpdateTx = new NodeUpdateTransaction().setNodeId(nodeId);
const newPrivateKey = PrivateKey.generateED25519();
nodeUpdateTx = nodeUpdateTx.setAdminKey(newPrivateKey.publicKey);
nodeUpdateTx = nodeUpdateTx.freezeWith(nodeClient);
nodeUpdateTx = await nodeUpdateTx.sign(newPrivateKey);
const signedTx = await nodeUpdateTx.sign(adminKey);
const txResp = await signedTx.execute(nodeClient);
const nodeUpdateReceipt = await txResp.getReceipt(nodeClient);
self.logger.debug(`NodeUpdateReceipt: ${nodeUpdateReceipt.toString()} for node ${nodeAlias}`);
// save new key in k8s secret
const data = {
privateKey: Base64.encode(newPrivateKey.toString()),
publicKey: Base64.encode(newPrivateKey.publicKey.toString()),
};
await this.k8Factory
.default()
.secrets()
.create(
ctx.config.namespace,
Templates.renderNodeAdminKeyName(nodeAlias),
SecretType.OPAQUE,
data,
{
'solo.hedera.com/node-admin-key': 'true',
},
);
} catch (e) {
throw new SoloError(`Error updating admin key for node ${nodeAlias}: ${e.message}`, e);
}
}
},
},
{
title: 'Display results',
task: ctx => {
self.logger.showUser(
chalk.green(`Account keys updated SUCCESSFULLY: ${ctx.resultTracker.fulfilledCount}`),
);
if (ctx.resultTracker.skippedCount > 0)
self.logger.showUser(
chalk.cyan(`Account keys updates SKIPPED: ${ctx.resultTracker.skippedCount}`),
);
if (ctx.resultTracker.rejectedCount > 0) {
self.logger.showUser(
chalk.yellowBright(`Account keys updates with ERROR: ${ctx.resultTracker.rejectedCount}`),
);
}
self.logger.showUser(chalk.gray('Waiting for sockets to be closed....'));
if (ctx.resultTracker.rejectedCount > 0) {
throw new SoloError(
`Account keys updates failed for ${ctx.resultTracker.rejectedCount} accounts.`,
);
}
},
},
],
{
concurrent: false,
rendererOptions: {
collapseSubtasks: false,
},
},
);
},
},
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e: Error | any) {
throw new SoloError(`Error in creating account: ${e.message}`, e);
} finally {
await this.closeConnections();
// create two accounts to force the handler to trigger
await self.create({});
await self.create({});
}
return true;
}
async create(argv: any) {
const self = this;
const lease = await self.leaseManager.create();
interface Context {
config: {
amount: number;
ecdsaPrivateKey: string;
ed25519PrivateKey: string;
namespace: NamespaceName;
setAlias: boolean;
generateEcdsaKey: boolean;
createAmount: number;
};
privateKey: PrivateKey;
}
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
self.configManager.update(argv);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const config = {
amount: self.configManager.getFlag<number>(flags.amount) as number,
ecdsaPrivateKey: self.configManager.getFlag<string>(flags.ecdsaPrivateKey) as string,
namespace: namespace,
ed25519PrivateKey: self.configManager.getFlag<string>(flags.ed25519PrivateKey) as string,
setAlias: self.configManager.getFlag<boolean>(flags.setAlias) as boolean,
generateEcdsaKey: self.configManager.getFlag<boolean>(flags.generateEcdsaKey) as boolean,
createAmount: self.configManager.getFlag<number>(flags.createAmount) as number,
};
if (!config.amount) {
config.amount = flags.amount.definition.defaultValue as number;
}
if (!(await this.k8Factory.default().namespaces().has(config.namespace))) {
throw new SoloError(`namespace ${config.namespace} does not exist`);
}
// set config in the context for later tasks to use
ctx.config = config;
self.logger.debug('Initialized config', {config});
await self.accountManager.loadNodeClient(
ctx.config.namespace,
self.getClusterRefs(),
self.configManager.getFlag<DeploymentName>(flags.deployment),
self.configManager.getFlag<boolean>(flags.forcePortForward),
);
return ListrLease.newAcquireLeaseTask(lease, task);
},
},
{
title: 'create the new account',
task: async ctx => {
for (let i = 0; i < ctx.config.createAmount; i++) {
self.accountInfo = await self.createNewAccount(ctx);
const accountInfoCopy = {...self.accountInfo};
delete accountInfoCopy.privateKey;
this.logger.showJSON('new account created', accountInfoCopy);
if (ctx.config.createAmount > 0) {
await sleep(Duration.ofSeconds(1));
}
}
},
},
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e: Error | any) {
throw new SoloError(`Error in creating account: ${e.message}`, e);
} finally {
await lease.release();
await this.closeConnections();
}
return true;
}
async update(argv: any) {
const self = this;
interface Context {
config: {
accountId: string;
amount: number;
namespace: NamespaceName;
ecdsaPrivateKey: string;
ed25519PrivateKey: string;
};
accountInfo: {accountId: string; balance: number; publicKey: string; privateKey?: string};
}
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
self.configManager.update(argv);
await self.configManager.executePrompt(task, [flags.accountId]);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const config = {
accountId: self.configManager.getFlag<string>(flags.accountId) as string,
amount: self.configManager.getFlag<number>(flags.amount) as number,
namespace: namespace,
ecdsaPrivateKey: self.configManager.getFlag<string>(flags.ecdsaPrivateKey) as string,
ed25519PrivateKey: self.configManager.getFlag<string>(flags.ed25519PrivateKey) as string,
};
if (!(await this.k8Factory.default().namespaces().has(config.namespace))) {
throw new SoloError(`namespace ${config.namespace} does not exist`);
}
// set config in the context for later tasks to use
ctx.config = config;
await self.accountManager.loadNodeClient(
config.namespace,
self.getClusterRefs(),
self.configManager.getFlag<DeploymentName>(flags.deployment),
self.configManager.getFlag<boolean>(flags.forcePortForward),
);
self.logger.debug('Initialized config', {config});
},
},
{
title: 'get the account info',
task: async ctx => {
ctx.accountInfo = await self.buildAccountInfo(
await self.getAccountInfo(ctx),
ctx.config.namespace,
!!ctx.config.ed25519PrivateKey,
);
},
},
{
title: 'update the account',
task: async ctx => {
if (!(await self.updateAccountInfo(ctx))) {
throw new SoloError(`An error occurred updating account ${ctx.accountInfo.accountId}`);
}
},
},
{
title: 'get the updated account info',
task: async ctx => {
self.accountInfo = await self.buildAccountInfo(await self.getAccountInfo(ctx), ctx.config.namespace, false);
this.logger.showJSON('account info', self.accountInfo);
},
},
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e: Error | any) {
throw new SoloError(`Error in updating account: ${e.message}`, e);
} finally {
await this.closeConnections();
}
return true;
}
async get(argv: any) {
const self = this;
interface Context {
config: {
accountId: string;
namespace: NamespaceName;
privateKey: boolean;
};
}
// @ts-ignore
const tasks = new Listr<Context>(
[
{
title: 'Initialize',
task: async (ctx, task) => {
self.configManager.update(argv);
await self.configManager.executePrompt(task, [flags.accountId]);
const namespace = await resolveNamespaceFromDeployment(this.localConfig, this.configManager, task);
const config = {
accountId: self.configManager.getFlag<string>(flags.accountId) as string,
namespace: namespace,
privateKey: self.configManager.getFlag<boolean>(flags.privateKey) as boolean,
};
if (!(await this.k8Factory.default().namespaces().has(config.namespace))) {
throw new SoloError(`namespace ${config.namespace} does not exist`);
}
// set config in the context for later tasks to use
ctx.config = config;
await self.accountManager.loadNodeClient(
config.namespace,
self.getClusterRefs(),
self.configManager.getFlag<DeploymentName>(flags.deployment),
self.configManager.getFlag<boolean>(flags.forcePortForward),
);
self.logger.debug('Initialized config', {config});
},
},
{
title: 'get the account info',
task: async ctx => {
self.accountInfo = await self.buildAccountInfo(
await self.getAccountInfo(ctx),
ctx.config.namespace,
ctx.config.privateKey,
);
this.logger.showJSON('account info', self.accountInfo);
},
},
],
{
concurrent: false,
rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION,
},
);
try {
await tasks.run();
} catch (e: Error | any) {
throw new SoloError(`Error in getting account info: ${e.message}`, e);
} finally {
await this.closeConnections();
}
return true;
}
/** Return Yargs command definition for 'node' command */
getCommandDefinition(): {command: string; desc: string; builder: CommandBuilder} {
const self = this;
return {
command: 'account',
desc: 'Manage Hedera accounts in solo network',
builder: (yargs: any) => {
return yargs
.command({
command: 'init',
desc: 'Initialize system accounts with new keys',
builder: (y: any) => flags.setCommandFlags(y, flags.deployment, flags.nodeAliasesUnparsed),
handler: (argv: any) => {
self.logger.info("==== Running 'account init' ===");
self.logger.info(argv);
self
.init(argv)
.then(r => {
self.logger.info("==== Finished running 'account init' ===");
if (!r) process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.command({
command: 'create',
desc: 'Creates a new account with a new key and stores the key in the Kubernetes secrets, if you supply no key one will be generated for you, otherwise you may supply either a ECDSA or ED25519 private key',
builder: (y: any) =>
flags.setCommandFlags(
y,
flags.amount,
flags.createAmount,
flags.ecdsaPrivateKey,
flags.deployment,
flags.ed25519PrivateKey,
flags.generateEcdsaKey,
flags.setAlias,
),
handler: (argv: any) => {
self.logger.info("==== Running 'account create' ===");
self.logger.info(argv);
self
.create(argv)
.then(r => {
self.logger.info("==== Finished running 'account create' ===");
if (!r) process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.command({
command: 'update',
desc: 'Updates an existing account with the provided info, if you want to update the private key, you can supply either ECDSA or ED25519 but not both\n',
builder: (y: any) =>
flags.setCommandFlags(
y,
flags.accountId,
flags.amount,
flags.deployment,
flags.ecdsaPrivateKey,
flags.ed25519PrivateKey,
),
handler: (argv: any) => {
self.logger.info("==== Running 'account update' ===");
self.logger.info(argv);
self
.update(argv)
.then(r => {
self.logger.info("==== Finished running 'account update' ===");
if (!r) process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.command({
command: 'get',
desc: 'Gets the account info including the current amount of HBAR',
builder: (y: any) => flags.setCommandFlags(y, flags.accountId, flags.privateKey, flags.deployment),
handler: (argv: any) => {
self.logger.info("==== Running 'account get' ===");
self.logger.info(argv);
self
.get(argv)
.then(r => {
self.logger.info("==== Finished running 'account get' ===");
if (!r) process.exit(1);
})
.catch(err => {
self.logger.showUserError(err);
process.exit(1);
});
},
})
.demandCommand(1, 'Select an account command');
},
};
}
close(): Promise<void> {
return this.closeConnections();
}
}