From 30b8bd488a36dcaebf648be19052af60ce7b81c8 Mon Sep 17 00:00:00 2001 From: Vladimir Gorkavenko <32727352+vgorkavenko@users.noreply.github.com> Date: Mon, 8 Apr 2024 19:35:53 +0400 Subject: [PATCH] Feat/cli (#8) * chore: misc * fix: daemon * chore: logs and exit * fix: contracts * feat: progress bar -> spinner * chore: refactor prover service * feat: cli --- .env.example | 10 -- package.json | 8 +- src/cli/cli.module.ts | 9 +- src/cli/cli.service.ts | 6 +- src/cli/commands/prove.command.ts | 110 ++++++++++++++++++ src/cli/questions/proof-input.question.ts | 35 ++++++ src/cli/questions/tx-execution.question.ts | 14 +++ src/common/contracts/csm-contract.service.ts | 5 + .../contracts/verifier-contract.service.ts | 2 - src/common/prover/duties/slashings.ts | 2 +- src/common/prover/duties/withdrawals.ts | 7 +- src/common/prover/prover.service.ts | 38 ++++-- src/common/providers/consensus/consensus.ts | 15 ++- src/common/providers/execution/execution.ts | 83 +++++++++---- src/common/providers/providers.module.ts | 4 +- .../download-progress/download-progress.ts | 55 +++------ src/daemon/daemon.service.ts | 10 +- src/daemon/services/roots-processor.ts | 6 +- src/main.ts | 16 ++- yarn.lock | 54 +++++++-- 20 files changed, 361 insertions(+), 128 deletions(-) create mode 100644 src/cli/commands/prove.command.ts create mode 100644 src/cli/questions/proof-input.question.ts create mode 100644 src/cli/questions/tx-execution.question.ts diff --git a/.env.example b/.env.example index e9ba4d8..557c1b9 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,6 @@ -# CLI working mode ETH_NETWORK=1 EL_RPC_URLS=https://mainnet.infura.io/v3/... CL_API_URLS=https://quiknode.pro/... CSM_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320 VERIFIER_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6321 TX_SIGNER_PRIVATE_KEY=0x... - -# Daemon working mode -ETH_NETWORK=1 -EL_RPC_URLS=https://mainnet.infura.io/v3/... -CL_API_URLS=https://quiknode.pro/... -KEYSAPI_API_URLS=https://keys-api.lido.fi/ -CSM_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320 -VERIFIER_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6321 -TX_SIGNER_PRIVATE_KEY=0x... diff --git a/package.json b/package.json index 12fa7c6..f1c3da4 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,12 @@ "private": true, "license": "GPL-3.0", "scripts": { + "prove": "nest build && NODE_OPTIONS=--max_old_space_size=8192 WORKING_MODE=cli node dist/main prove", + "prove:debug": "nest build && NODE_OPTIONS=--max_old_space_size=8192 WORKING_MODE=cli node --inspect dist/main prove", + "slashing": "yarn prove slashing", + "slashing:debug": "yarn prove:debug slashing", + "withdrawal": "yarn prove withdrawal", + "withdrawal:debug": "yarn prove:debug withdrawal", "typechain": "typechain --target=ethers-v5 --out-dir src/common/contracts/types src/common/contracts/abi/*.json", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -37,10 +43,10 @@ "@types/cli-progress": "^3.11.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", - "cli-progress": "^3.12.0", "ethers": "^5.7.2", "nest-commander": "^3.12.5", "nest-winston": "^1.9.4", + "ora-classic": "^5.4.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "stream-chain": "^2.2.5", diff --git a/src/cli/cli.module.ts b/src/cli/cli.module.ts index e68db22..2cea9cc 100644 --- a/src/cli/cli.module.ts +++ b/src/cli/cli.module.ts @@ -1,13 +1,18 @@ import { Module } from '@nestjs/common'; import { CliService } from './cli.service'; +import { ProveCommand } from './commands/prove.command'; +import { ProofInputQuestion } from './questions/proof-input.question'; +import { TxExecutionQuestion } from './questions/tx-execution.question'; import { ConfigModule } from '../common/config/config.module'; +import { ContractsModule } from '../common/contracts/contracts.module'; import { LoggerModule } from '../common/logger/logger.module'; import { ProverModule } from '../common/prover/prover.module'; import { ProvidersModule } from '../common/providers/providers.module'; @Module({ - imports: [LoggerModule, ConfigModule, ProvidersModule, ProverModule], - providers: [CliService], + imports: [LoggerModule, ConfigModule, ContractsModule, ProvidersModule, ProverModule], + providers: [CliService, ProveCommand, ProofInputQuestion, TxExecutionQuestion], + exports: [CliService, ProveCommand, ProofInputQuestion, TxExecutionQuestion], }) export class CliModule {} diff --git a/src/cli/cli.service.ts b/src/cli/cli.service.ts index cb18225..6123887 100644 --- a/src/cli/cli.service.ts +++ b/src/cli/cli.service.ts @@ -1,10 +1,10 @@ import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; -import { Inject, Injectable, LoggerService, OnApplicationBootstrap } from '@nestjs/common'; +import { Inject, Injectable, LoggerService, OnModuleInit } from '@nestjs/common'; @Injectable() -export class CliService implements OnApplicationBootstrap { +export class CliService implements OnModuleInit { constructor(@Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService) {} - async onApplicationBootstrap() { + async onModuleInit() { this.logger.log('Working mode: CLI'); } } diff --git a/src/cli/commands/prove.command.ts b/src/cli/commands/prove.command.ts new file mode 100644 index 0000000..2e50c2f --- /dev/null +++ b/src/cli/commands/prove.command.ts @@ -0,0 +1,110 @@ +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { Inject, LoggerService } from '@nestjs/common'; +import { Command as Commander } from 'commander'; +import { Command, CommandRunner, InjectCommander, InquirerService, Option } from 'nest-commander'; + +import { CsmContract } from '../../common/contracts/csm-contract.service'; +import { ProverService } from '../../common/prover/prover.service'; +import { KeyInfoFn } from '../../common/prover/types'; +import { Consensus } from '../../common/providers/consensus/consensus'; +import { Execution } from '../../common/providers/execution/execution'; + +type ProofOptions = { + nodeOperatorId: string; + keyIndex: string; + validatorIndex: string; + block: string; +}; + +@Command({ + name: 'prove', + description: 'Prove a withdrawal or slashing', + arguments: '', + argsDescription: { + withdrawal: 'Prove a withdrawal', + slashing: 'Prove a slashing', + }, +}) +export class ProveCommand extends CommandRunner { + private options: ProofOptions; + private pubkey: string; + + constructor( + @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + @InjectCommander() private readonly commander: Commander, + protected readonly inquirerService: InquirerService, + protected readonly csm: CsmContract, + protected readonly consensus: Consensus, + protected readonly execution: Execution, + protected readonly prover: ProverService, + ) { + super(); + } + + async run(inputs: string[], options?: ProofOptions) { + try { + this.options = await this.inquirerService.ask('proof-input', options); + this.logger.debug!(this.options); + this.pubkey = await this.csm.getNodeOperatorKey(this.options.nodeOperatorId, this.options.keyIndex); + this.logger.debug!(`Validator public key: ${this.pubkey}`); + const header = await this.consensus.getBeaconHeader('finalized'); + this.logger.debug!(`Finalized slot [${header.header.message.slot}]. Root [${header.root}]`); + const { root: blockRootToProcess } = await this.consensus.getBeaconHeader(this.options.block); + const blockInfoToProcess = await this.consensus.getBlockInfo(this.options.block); + this.logger.debug!(`Block to process [${this.options.block}]`); + + switch (inputs[0]) { + case 'withdrawal': + await this.prover.handleWithdrawalsInBlock(blockRootToProcess, blockInfoToProcess, header, this.keyInfoFn); + break; + case 'slashing': + await this.prover.handleSlashingsInBlock(blockInfoToProcess, header, this.keyInfoFn); + break; + } + } catch (e) { + this.commander.error(e); + } + } + + @Option({ + flags: '--node-operator-id ', + description: 'Node Operator ID from the CSM', + }) + parseNodeOperatorId(val: string) { + return val; + } + + @Option({ + flags: '--key-index ', + description: 'Key Index from the CSM', + }) + parseKeyIndex(val: string) { + return val; + } + + @Option({ + flags: '--validator-index ', + description: 'Validator Index from the Consensus Layer', + }) + parseValidatorIndex(val: string) { + return val; + } + + @Option({ + flags: '--block ', + description: 'Block from the Consensus Layer with validator withdrawal. Might be a block root or a slot number', + }) + parseBlock(val: string) { + return val; + } + + keyInfoFn: KeyInfoFn = (valIndex: number) => { + if (valIndex === Number(this.options.validatorIndex)) { + return { + operatorId: Number(this.options.nodeOperatorId), + keyIndex: Number(this.options.keyIndex), + pubKey: this.pubkey, + }; + } + }; +} diff --git a/src/cli/questions/proof-input.question.ts b/src/cli/questions/proof-input.question.ts new file mode 100644 index 0000000..b846d43 --- /dev/null +++ b/src/cli/questions/proof-input.question.ts @@ -0,0 +1,35 @@ +import { Question, QuestionSet } from 'nest-commander'; + +@QuestionSet({ name: 'proof-input' }) +export class ProofInputQuestion { + @Question({ + message: 'Node operator ID:', + name: 'nodeOperatorId', + }) + parseNodeOperatorId(val: string) { + return val; + } + @Question({ + message: 'Key index:', + name: 'keyIndex', + }) + parseKeyIndex(val: string) { + return val; + } + + @Question({ + message: 'Validator index:', + name: 'validatorIndex', + }) + parseValidatorIndex(val: string) { + return val; + } + + @Question({ + message: 'Block (root or slot number):', + name: 'block', + }) + parseBlock(val: string) { + return val; + } +} diff --git a/src/cli/questions/tx-execution.question.ts b/src/cli/questions/tx-execution.question.ts new file mode 100644 index 0000000..248eb9b --- /dev/null +++ b/src/cli/questions/tx-execution.question.ts @@ -0,0 +1,14 @@ +import { Question, QuestionSet } from 'nest-commander'; + +@QuestionSet({ name: 'tx-execution' }) +export class TxExecutionQuestion { + @Question({ + type: 'confirm', + askAnswered: true, + message: 'Are you sure you want to send this transaction?', + name: 'sendingConfirmed', + }) + parseSendingConfirmed(val: boolean) { + return val; + } +} diff --git a/src/common/contracts/csm-contract.service.ts b/src/common/contracts/csm-contract.service.ts index d227c73..8eaff3e 100644 --- a/src/common/contracts/csm-contract.service.ts +++ b/src/common/contracts/csm-contract.service.ts @@ -23,4 +23,9 @@ export class CsmContract { public async isWithdrawalProved(keyInfo: KeyInfo): Promise { return await this.impl.isValidatorWithdrawn(keyInfo.operatorId, keyInfo.keyIndex); } + + public async getNodeOperatorKey(nodeOperatorId: string | number, keyIndex: string | number): Promise { + const [key] = await this.impl.getNodeOperatorSigningKeys(nodeOperatorId, keyIndex, 1); + return key; + } } diff --git a/src/common/contracts/verifier-contract.service.ts b/src/common/contracts/verifier-contract.service.ts index eff2dc6..2ce97c2 100644 --- a/src/common/contracts/verifier-contract.service.ts +++ b/src/common/contracts/verifier-contract.service.ts @@ -19,7 +19,6 @@ export class VerifierContract { } public async sendSlashingProof(payload: SlashingProofPayload): Promise { - this.logger.debug!(payload); await this.execution.execute( this.impl.callStatic.processSlashingProof, this.impl.populateTransaction.processSlashingProof, @@ -28,7 +27,6 @@ export class VerifierContract { } public async sendWithdrawalProof(payload: WithdrawalsProofPayload): Promise { - this.logger.debug!(payload); await this.execution.execute( this.impl.callStatic.processWithdrawalProof, this.impl.populateTransaction.processWithdrawalProof, diff --git a/src/common/prover/duties/slashings.ts b/src/common/prover/duties/slashings.ts index 1ab6d5f..a72ac61 100644 --- a/src/common/prover/duties/slashings.ts +++ b/src/common/prover/duties/slashings.ts @@ -45,11 +45,11 @@ export class SlashingsService { public async sendSlashingProof(finalizedHeader: BlockHeaderResponse, slashings: InvolvedKeys): Promise { if (!Object.keys(slashings).length) return; - this.logger.log(`Getting state for root [${finalizedHeader.root}]`); const finalizedState = await this.consensus.getState(finalizedHeader.header.message.state_root); const nextHeader = (await this.consensus.getBeaconHeadersByParentRoot(finalizedHeader.root)).data[0]; const nextHeaderTs = this.consensus.slotToTimestamp(Number(nextHeader.header.message.slot)); const stateView = this.consensus.stateToView(finalizedState.bodyBytes, finalizedState.forkName); + this.logger.log(`Building slashing proof payloads`); const payloads = this.buildSlashingsProofPayloads(finalizedHeader, nextHeaderTs, stateView, slashings); for (const payload of payloads) { this.logger.warn(`๐Ÿ“ก Sending slashing proof payload for validator index: ${payload.witness.validatorIndex}`); diff --git a/src/common/prover/duties/withdrawals.ts b/src/common/prover/duties/withdrawals.ts index 5c5f014..251dd1a 100644 --- a/src/common/prover/duties/withdrawals.ts +++ b/src/common/prover/duties/withdrawals.ts @@ -25,7 +25,7 @@ let ssz: typeof import('@lodestar/types').ssz; let anySsz: typeof ssz.phase0 | typeof ssz.altair | typeof ssz.bellatrix | typeof ssz.capella | typeof ssz.deneb; // according to the research https://hackmd.io/1wM8vqeNTjqt4pC3XoCUKQ?view#Proposed-solution -const FULL_WITHDRAWAL_MIN_AMOUNT = 8 * 10 ** 18; // 8 ETH +const FULL_WITHDRAWAL_MIN_AMOUNT = 8 * 10 ** 9; // 8 ETH in Gwei type WithdrawalWithOffset = Withdrawal & { offset: number }; type InvolvedKeysWithWithdrawal = { [valIndex: string]: KeyInfo & { withdrawal: WithdrawalWithOffset } }; @@ -67,7 +67,6 @@ export class WithdrawalsService { ): Promise { if (!Object.keys(withdrawals).length) return; const blockHeader = await this.consensus.getBeaconHeader(blockRoot); - this.logger.log(`Getting state for root [${blockRoot}]`); const state = await this.consensus.getState(blockHeader.header.message.state_root); // There is a case when the block is not historical regarding the finalized block, but it is historical // regarding the transaction execution time. This is possible when long finalization time @@ -89,6 +88,7 @@ export class WithdrawalsService { // create proof against the state with withdrawals const nextBlockHeader = (await this.consensus.getBeaconHeadersByParentRoot(blockHeader.root)).data[0]; const nextBlockTs = this.consensus.slotToTimestamp(Number(nextBlockHeader.header.message.slot)); + this.logger.log(`Building withdrawal proof payloads`); const payloads = this.buildWithdrawalsProofPayloads( blockHeader, nextBlockTs, @@ -112,12 +112,11 @@ export class WithdrawalsService { // create proof against the historical state with withdrawals const nextBlockHeader = (await this.consensus.getBeaconHeadersByParentRoot(finalizedHeader.root)).data[0]; const nextBlockTs = this.consensus.slotToTimestamp(Number(nextBlockHeader.header.message.slot)); - this.logger.log(`Getting state for root [${finalizedHeader.root}]`); const finalizedState = await this.consensus.getState(finalizedHeader.header.message.state_root); const summaryIndex = this.calcSummaryIndex(blockInfo); const summarySlot = this.calcSlotOfSummary(summaryIndex); - this.logger.log(`Getting state for slot [${summarySlot}]`); const summaryState = await this.consensus.getState(summarySlot); + this.logger.log(`Building historical withdrawal proof payloads`); const payloads = this.buildHistoricalWithdrawalsProofPayloads( blockHeader, finalizedHeader, diff --git a/src/common/prover/prover.service.ts b/src/common/prover/prover.service.ts index 81d3f53..6cecd3d 100644 --- a/src/common/prover/prover.service.ts +++ b/src/common/prover/prover.service.ts @@ -5,7 +5,7 @@ import { SlashingsService } from './duties/slashings'; import { WithdrawalsService } from './duties/withdrawals'; import { KeyInfoFn } from './types'; import { Consensus } from '../providers/consensus/consensus'; -import { BlockInfoResponse, RootHex } from '../providers/consensus/response.interface'; +import { BlockHeaderResponse, BlockInfoResponse, RootHex } from '../providers/consensus/response.interface'; @Injectable() export class ProverService { @@ -19,19 +19,39 @@ export class ProverService { public async handleBlock( blockRoot: RootHex, blockInfo: BlockInfoResponse, - finalizedBlockRoot: RootHex, + finalizedHeader: BlockHeaderResponse, + keyInfoFn: KeyInfoFn, + ): Promise { + await this.handleWithdrawalsInBlock(blockRoot, blockInfo, finalizedHeader, keyInfoFn); + await this.handleSlashingsInBlock(blockInfo, finalizedHeader, keyInfoFn); + } + + public async handleWithdrawalsInBlock( + blockRoot: RootHex, + blockInfo: BlockInfoResponse, + finalizedHeader: BlockHeaderResponse, keyInfoFn: KeyInfoFn, ): Promise { - const slashings = await this.slashings.getUnprovenSlashings(blockInfo, keyInfoFn); const withdrawals = await this.withdrawals.getUnprovenWithdrawals(blockInfo, keyInfoFn); - if (!Object.keys(slashings).length && !Object.keys(withdrawals).length) { - this.logger.log('Nothing to prove'); + if (!Object.keys(withdrawals).length) { + this.logger.log('No withdrawals to prove'); return; } - const finalizedHeader = await this.consensus.getBeaconHeader(finalizedBlockRoot); - // do it consistently because of the high resource usage (both the app and CL node) - await this.slashings.sendSlashingProof(finalizedHeader, slashings); await this.withdrawals.sendWithdrawalProofs(blockRoot, blockInfo, finalizedHeader, withdrawals); - this.logger.log('๐Ÿ Proof(s) sent'); + this.logger.log('๐Ÿ Withdrawal proof(s) sent'); + } + + public async handleSlashingsInBlock( + blockInfo: BlockInfoResponse, + finalizedHeader: BlockHeaderResponse, + keyInfoFn: KeyInfoFn, + ): Promise { + const slashings = await this.slashings.getUnprovenSlashings(blockInfo, keyInfoFn); + if (!Object.keys(slashings).length) { + this.logger.log('No slashings to prove'); + return; + } + await this.slashings.sendSlashingProof(finalizedHeader, slashings); + this.logger.log('๐Ÿ Slashing proof(s) sent'); } } diff --git a/src/common/providers/consensus/consensus.ts b/src/common/providers/consensus/consensus.ts index b012f6a..73332b4 100644 --- a/src/common/providers/consensus/consensus.ts +++ b/src/common/providers/consensus/consensus.ts @@ -1,6 +1,7 @@ import { ContainerTreeViewType } from '@chainsafe/ssz/lib/view/container'; import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; import { Inject, Injectable, LoggerService, OnModuleInit, Optional } from '@nestjs/common'; +import ora from 'ora'; import { BeaconConfig, @@ -39,8 +40,8 @@ export class Consensus extends BaseRestProvider implements OnModuleInit { constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, @Optional() protected readonly prometheus: PrometheusService, + @Optional() protected readonly progress: DownloadProgress, protected readonly config: ConfigService, - protected readonly progress: DownloadProgress, ) { super( config.get('CL_API_URLS') as Array, @@ -107,14 +108,18 @@ export class Consensus extends BaseRestProvider implements OnModuleInit { stateId: StateId, signal?: AbortSignal, ): Promise<{ bodyBytes: Uint8Array; forkName: keyof typeof ForkName }> { - const { body, headers } = await this.baseGet(this.mainUrl, this.endpoints.state(stateId), { + const requestPromise = this.baseGet(this.mainUrl, this.endpoints.state(stateId), { signal, headers: { accept: 'application/octet-stream' }, }); + if (this.progress) { + ora.promise(requestPromise, { text: `Getting state response for state id [${stateId}]` }); + } else { + this.logger.log(`Getting state response for state id [${stateId}]`); + } + const { body, headers } = await requestPromise; + this.progress?.show('State downloading', { body, headers }); const forkName = headers['eth-consensus-version'] as keyof typeof ForkName; - // Progress bar - // TODO: Enable for CLI only - //this.progress.show(`State [${stateId}]`, resp); const bodyBytes = new Uint8Array(await body.arrayBuffer()); return { bodyBytes, forkName }; } diff --git a/src/common/providers/execution/execution.ts b/src/common/providers/execution/execution.ts index a0e0030..52191f9 100644 --- a/src/common/providers/execution/execution.ts +++ b/src/common/providers/execution/execution.ts @@ -1,10 +1,29 @@ import { MAX_BLOCKCOUNT, SimpleFallbackJsonRpcBatchProvider } from '@lido-nestjs/execution'; import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; -import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { Inject, Injectable, LoggerService, Optional } from '@nestjs/common'; import { PopulatedTransaction, Wallet, utils } from 'ethers'; +import { InquirerService } from 'nest-commander'; import { bigIntMax, bigIntMin, percentile } from './utils/common'; import { ConfigService } from '../../config/config.service'; +import { WorkingMode } from '../../config/env.validation'; + +class ErrorWithContext extends Error { + public readonly context: any; + + constructor(message?: string, ctx?: any) { + super(message); + this.context = ctx; + } +} + +class EmulatedCallError extends ErrorWithContext {} +class SendTransactionError extends ErrorWithContext {} +class HighGasFeeError extends ErrorWithContext {} +class UserCancellationError extends ErrorWithContext {} + +class NoSignerError extends ErrorWithContext {} +class DryRunError extends ErrorWithContext {} @Injectable() export class Execution { @@ -16,6 +35,7 @@ export class Execution { constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, protected readonly config: ConfigService, + @Optional() protected readonly inquirerService: InquirerService, public readonly provider: SimpleFallbackJsonRpcBatchProvider, ) { const key = this.config.get('TX_SIGNER_PRIVATE_KEY'); @@ -26,17 +46,35 @@ export class Execution { emulateTxCallback: (...payload: any[]) => Promise, populateTxCallback: (...payload: any[]) => Promise, payload: any[], + ): Promise { + try { + await this._execute(emulateTxCallback, populateTxCallback, payload); + } catch (e) { + if (e instanceof NoSignerError || e instanceof DryRunError) { + this.logger.warn(e); + return; + } + this.logger.error(e); + throw e; + } + } + + private async _execute( + emulateTxCallback: (...payload: any[]) => Promise, + populateTxCallback: (...payload: any[]) => Promise, + payload: any[], ): Promise { this.logger.debug!(payload); const tx = await populateTxCallback(...payload); - await emulateTxCallback(...payload).catch((e) => { - this.logger.error('โŒ Emulated call failed'); - throw e; - }); - this.logger.log('โœ… Emulated call succeeded'); + let context: { payload: any[]; tx?: any } = { payload, tx }; + try { + await emulateTxCallback(...payload); + this.logger.log('โœ… Emulated call succeeded'); + } catch (e) { + throw new EmulatedCallError(e, context); + } if (!this.signer) { - this.logger.warn('โœด๏ธ No specified signer. Only emulated calls are available'); - return; + throw new NoSignerError('No specified signer. Only emulated calls are available', context); } const priorityFeeParams = await this.calcPriorityFee(); const populated = await this.signer.populateTransaction({ @@ -45,28 +83,27 @@ export class Execution { maxPriorityFeePerGas: priorityFeeParams.maxPriorityFeePerGas, gasLimit: this.config.get('TX_GAS_LIMIT'), }); - // TODO: For CLI: - // ask before sending; - // suggest to send tx even if high gas fee; - // suggest to specify custom gas fee (especially if gas history fails) + context = { ...context, tx: populated }; if (this.config.get('DRY_RUN')) { - this.logger.warn('โœด๏ธ Dry run mode is enabled. Transaction is prepared, but not sent:'); - this.logger.warn(populated); - return; + throw new DryRunError('Dry run mode is enabled. Transaction is prepared, but not sent', context); } const isFeePerGasAcceptable = await this.isFeePerGasAcceptable(); - if (!isFeePerGasAcceptable) { - this.logger.warn('โŒ Transaction is not sent due to high gas fee:'); - this.logger.warn(populated); - throw new Error('Transaction is not sent due to high gas fee'); + if (this.config.get('WORKING_MODE') == WorkingMode.CLI) { + const opts = await this.inquirerService.ask('tx-execution', {} as { sendingConfirmed: boolean }); + if (!opts.sendingConfirmed) { + throw new UserCancellationError('Transaction is not sent due to user cancellation', context); + } + } else { + if (!isFeePerGasAcceptable) { + throw new HighGasFeeError('Transaction is not sent due to high gas fee', context); + } } const signed = await this.signer.signTransaction(populated); try { const submitted = await this.provider.sendTransaction(signed); await submitted.wait(); } catch (e) { - this.logger.error('โŒ Transaction failed'); - throw e; + throw new SendTransactionError(e, context); } this.logger.log('โœ… Transaction succeeded'); } @@ -77,8 +114,8 @@ export class Execution { private async isFeePerGasAcceptable(): Promise { const { current, recommended } = await this.calcFeePerGas(); - const currentGwei = Number(utils.formatUnits(current, 'gwei')).toFixed(2); - const recommendedGwei = Number(utils.formatUnits(recommended, 'gwei')).toFixed(2); + const currentGwei = utils.formatUnits(current, 'gwei'); + const recommendedGwei = utils.formatUnits(recommended, 'gwei'); const info = `Current: ${currentGwei} Gwei | Recommended: ${recommendedGwei} Gwei`; if (current > recommended) { this.logger.warn(`๐Ÿ“› Current gas fee is HIGH! ${info}`); diff --git a/src/common/providers/providers.module.ts b/src/common/providers/providers.module.ts index 17e12a4..7aa6cde 100644 --- a/src/common/providers/providers.module.ts +++ b/src/common/providers/providers.module.ts @@ -36,13 +36,15 @@ const ExecutionCli = () => @Module({ imports: [ - UtilsModule, ConditionalModule.registerWhen(ExecutionDaemon(), (env: NodeJS.ProcessEnv) => { return env['WORKING_MODE'] === WorkingMode.Daemon; }), ConditionalModule.registerWhen(ExecutionCli(), (env: NodeJS.ProcessEnv) => { return env['WORKING_MODE'] === WorkingMode.CLI; }), + ConditionalModule.registerWhen(UtilsModule, (env: NodeJS.ProcessEnv) => { + return env['WORKING_MODE'] === WorkingMode.CLI; + }), ], providers: [Execution, Consensus, Keysapi], exports: [Execution, Consensus, Keysapi], diff --git a/src/common/utils/download-progress/download-progress.ts b/src/common/utils/download-progress/download-progress.ts index 57d2d2f..60f527a 100644 --- a/src/common/utils/download-progress/download-progress.ts +++ b/src/common/utils/download-progress/download-progress.ts @@ -1,67 +1,40 @@ import { Injectable } from '@nestjs/common'; -import { MultiBar, Presets, SingleBar } from 'cli-progress'; +import ora, { Ora } from 'ora'; import { IncomingHttpHeaders } from 'undici/types/header'; import BodyReadable from 'undici/types/readable'; @Injectable() export class DownloadProgress { - private multibar: MultiBar; + private spinner: Ora; constructor() {} - // TODO: how it works when error occurs from other promises in the same time ? - public show(name: string, resp: { body: BodyReadable; headers: IncomingHttpHeaders }): void { const totalContentLength = Number(resp.headers['content-length']); let downloaded = 0; let speed = '0.00'; let downloadedMb = 0; - let ratio = 0; const start = Date.now(); - const bar = this.add(name, Number((totalContentLength / 1024 / 1024).toFixed(2))); + const dataSize = Number((totalContentLength / 1024 / 1024).toFixed(2)); + this.add(name, dataSize); resp.body.on('data', (chunk) => { downloaded += chunk.length; - ratio = downloaded / totalContentLength; downloadedMb = Number(downloaded / 1024 / 1024); speed = (downloadedMb / ((Date.now() - start) / 1000)).toFixed(2); - bar.update(ratio, { - speed, - downloaded: downloadedMb.toFixed(2), - status: 'โคต๏ธ ', - }); + this.spinner.text = `${name} | ${downloadedMb.toFixed(2)} MB / ${dataSize} MB | ${speed} MB/s`; }); - resp.body.on('end', () => renderFinish('โœ… Downloaded\n')); - resp.body.on('error', () => renderFinish('โŒ Failed\n')); - - const renderFinish = (status: string) => { - bar.update(ratio, { - speed, - downloaded: downloadedMb.toFixed(2), - status, - }); - bar.render(); - bar.stop(); - }; + resp.body.on('end', () => this.spinner.succeed()); + resp.body.on('error', () => this.spinner.fail()); } - private add(name: string, size: number): SingleBar { - if (!this.multibar) { - this.initMultibar(); + private add(name: string, size: number) { + if (!this.spinner) { + this.spinner = ora({ + text: `${name} | 0 MB / ${size} MB | 0.00 MB/s๏ธ`, + spinner: 'dots', + }); } - return this.multibar.create(1, 0, { name, size }); - } - - private initMultibar(): void { - this.multibar = new MultiBar( - { - fps: 1, - hideCursor: true, - noTTYOutput: true, - emptyOnZero: true, - format: ` | {name} |{bar}| {percentage}% || {downloaded} of {size} Mb | Speed: {speed} Mb/s | {status}`, - }, - Presets.shades_grey, - ); + this.spinner.start(); } } diff --git a/src/daemon/daemon.service.ts b/src/daemon/daemon.service.ts index d08e252..3193164 100644 --- a/src/daemon/daemon.service.ts +++ b/src/daemon/daemon.service.ts @@ -1,5 +1,5 @@ import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; -import { Inject, Injectable, LoggerService, OnApplicationBootstrap } from '@nestjs/common'; +import { Inject, Injectable, LoggerService, OnApplicationBootstrap, OnModuleInit } from '@nestjs/common'; import { KeysIndexer } from './services/keys-indexer'; import { RootsProcessor } from './services/roots-processor'; @@ -9,7 +9,7 @@ import { ConfigService } from '../common/config/config.service'; import { Consensus } from '../common/providers/consensus/consensus'; @Injectable() -export class DaemonService implements OnApplicationBootstrap { +export class DaemonService implements OnModuleInit, OnApplicationBootstrap { constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, protected readonly config: ConfigService, @@ -19,6 +19,10 @@ export class DaemonService implements OnApplicationBootstrap { protected readonly rootsProcessor: RootsProcessor, ) {} + async onModuleInit() { + this.logger.log('Working mode: DAEMON'); + } + async onApplicationBootstrap() { this.loop().then(); } @@ -41,7 +45,7 @@ export class DaemonService implements OnApplicationBootstrap { this.keysIndexer.update(header); const nextRoot = await this.rootsProvider.getNext(header); if (nextRoot) { - await this.rootsProcessor.process(nextRoot, header.root); + await this.rootsProcessor.process(nextRoot, header); return; } this.logger.log(`๐Ÿ’ค Wait for the next finalized root`); diff --git a/src/daemon/services/roots-processor.ts b/src/daemon/services/roots-processor.ts index 2b353c0..3048e0f 100644 --- a/src/daemon/services/roots-processor.ts +++ b/src/daemon/services/roots-processor.ts @@ -5,7 +5,7 @@ import { KeysIndexer } from './keys-indexer'; import { RootSlot, RootsStack } from './roots-stack'; import { ProverService } from '../../common/prover/prover.service'; import { Consensus } from '../../common/providers/consensus/consensus'; -import { RootHex } from '../../common/providers/consensus/response.interface'; +import { BlockHeaderResponse, RootHex } from '../../common/providers/consensus/response.interface'; @Injectable() export class RootsProcessor { @@ -17,7 +17,7 @@ export class RootsProcessor { protected readonly prover: ProverService, ) {} - public async process(blockRootToProcess: RootHex, finalizedRoot: RootHex): Promise { + public async process(blockRootToProcess: RootHex, finalizedHeader: BlockHeaderResponse): Promise { this.logger.log(`๐Ÿ›ƒ Root in processing [${blockRootToProcess}]`); const blockInfoToProcess = await this.consensus.getBlockInfo(blockRootToProcess); const rootSlot: RootSlot = { @@ -26,7 +26,7 @@ export class RootsProcessor { }; await this.rootsStack.push(rootSlot); // in case of revert we should reprocess the root // TODO: need some protection from run out of account's balance when tx reverting for the same root - await this.prover.handleBlock(blockRootToProcess, blockInfoToProcess, finalizedRoot, this.keysIndexer.getKey); + await this.prover.handleBlock(blockRootToProcess, blockInfoToProcess, finalizedHeader, this.keysIndexer.getKey); const indexerIsTrusted = this.keysIndexer.isTrustedForEveryDuty(rootSlot.slotNumber); if (indexerIsTrusted) await this.rootsStack.purge(rootSlot); await this.rootsStack.setLastProcessed(rootSlot); diff --git a/src/main.ts b/src/main.ts index 99551d9..0e9fa1d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,3 @@ -import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; import { NestFactory } from '@nestjs/core'; import { CommandFactory } from 'nest-commander'; @@ -8,19 +7,18 @@ import { WorkingMode } from './common/config/env.validation'; import { DaemonModule } from './daemon/daemon.module'; async function bootstrapCLI() { - const cliApp = await CommandFactory.createWithoutRunning(CliModule, { - bufferLogs: true, - }); - cliApp.useLogger(cliApp.get(LOGGER_PROVIDER)); + process + .on('SIGINT', () => process.exit()) // CTRL+C + .on('SIGQUIT', () => process.exit()) // Keyboard quit + .on('SIGTERM', () => process.exit()); // `kill` command + + const cliApp = await CommandFactory.createWithoutRunning(CliModule, { logger: false }); // disable initialising logs from NestJS await CommandFactory.runApplication(cliApp); await cliApp.close(); } async function bootstrapDaemon() { - const daemonApp = await NestFactory.create(DaemonModule, { - bufferLogs: true, - }); - daemonApp.useLogger(daemonApp.get(LOGGER_PROVIDER)); + const daemonApp = await NestFactory.create(DaemonModule, { logger: false }); // disable initialising logs from NestJS const configService: ConfigService = daemonApp.get(ConfigService); await daemonApp.listen(configService.get('HTTP_PORT'), '0.0.0.0'); } diff --git a/yarn.lock b/yarn.lock index b8c8848..199f03b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2640,13 +2640,6 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-progress@^3.12.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.12.0.tgz#807ee14b66bcc086258e444ad0f19e7d42577942" - integrity sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A== - dependencies: - string-width "^4.2.3" - cli-spinners@^2.5.0: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" @@ -5528,6 +5521,21 @@ optionator@^0.9.3: prelude-ls "^1.2.1" type-check "^0.4.0" +ora-classic@^5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/ora-classic/-/ora-classic-5.4.2.tgz#3d53a065d13a1f3b2f9c13ad89177e580f604588" + integrity sha512-/xX8D5AMHB+LnvEJHOglmq6pXwm65CQ/gqPrIjIN5GJ1Bl9KC9fSmgzR/FwjrtalDj/WVxukAVuH8GP00Zpiaw== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + ora@5.4.1, ora@^5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" @@ -6328,7 +6336,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6387,7 +6404,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7071,8 +7095,7 @@ wordwrapjs@^4.0.0: reduce-flatten "^2.0.0" typical "^5.2.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7090,6 +7113,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"