From 035905282096467249553a0fdaab2f42e6280d6c Mon Sep 17 00:00:00 2001 From: Rares <6453351+raress96@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:18:33 +0200 Subject: [PATCH] handle all todos and check available gas fees before sending transaction. --- .env.example | 2 + .env.test | 2 + .../approvals.processor.service.ts | 13 +- .../approvals.processor.spec.ts | 32 ++- .../processors/gateway.processor.spec.ts | 1 + .../message-approved.processor.service.ts | 60 ++++-- .../message-approved.processor.e2e-spec.ts | 143 +++++++++++-- libs/common/src/config/api.config.service.ts | 4 + libs/common/src/contracts/contracts.module.ts | 3 + .../src/contracts/entities/gas.error.ts | 2 + libs/common/src/contracts/fee.helper.spec.ts | 199 ++++++++++++++++++ libs/common/src/contracts/fee.helper.ts | 82 ++++++++ .../repository/message-approved.repository.ts | 27 ++- .../migration.sql | 2 + prisma/schema.prisma | 27 +-- 15 files changed, 516 insertions(+), 83 deletions(-) create mode 100644 libs/common/src/contracts/fee.helper.spec.ts create mode 100644 libs/common/src/contracts/fee.helper.ts create mode 100644 prisma/migrations/20241118081638_add_available_gas_balance/migration.sql diff --git a/.env.example b/.env.example index 84b7e66..cbb9e5d 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,5 @@ WALLET_MNEMONIC= CLIENT_CERT= CLIENT_KEY= + +ENABLE_GAS_CHECK=1 diff --git a/.env.test b/.env.test index f9ad5fd..7e062a0 100644 --- a/.env.test +++ b/.env.test @@ -24,3 +24,5 @@ WALLET_MNEMONIC="fitness horror fluid six mutual ahead upon zone install stadium CLIENT_CERT=test CLIENT_KEY=test + +ENABLE_GAS_CHECK=1 diff --git a/apps/axelar-event-processor/src/approvals-processor/approvals.processor.service.ts b/apps/axelar-event-processor/src/approvals-processor/approvals.processor.service.ts index 94769b4..78698de 100644 --- a/apps/axelar-event-processor/src/approvals-processor/approvals.processor.service.ts +++ b/apps/axelar-event-processor/src/approvals-processor/approvals.processor.service.ts @@ -209,8 +209,7 @@ export class ApprovalsProcessorService { } private async processExecuteTask(response: ExecuteTask, taskItemId: string) { - // TODO: Should we also save response.availableGasBalance and check if enough gas was payed before executing? - const messageApproved = await this.messageApprovedRepository.create({ + await this.messageApprovedRepository.createOrUpdate({ sourceChain: response.message.sourceChain, messageId: response.message.messageID, status: MessageApprovedStatus.PENDING, @@ -220,15 +219,9 @@ export class ApprovalsProcessorService { payload: Buffer.from(response.payload, 'base64'), retry: 0, taskItemId, + // Only support native token for gas + availableGasBalance: !response.availableGasBalance.tokenID ? response.availableGasBalance.amount : '0', }); - - if (!messageApproved) { - this.logger.warn( - `Couldn't save message approved to database, duplicate exists for source chain ${response.message.sourceChain} and message id ${response.message.messageID}`, - ); - - return; - } } private async processRefundTask(response: RefundTask) { diff --git a/apps/axelar-event-processor/src/approvals-processor/approvals.processor.spec.ts b/apps/axelar-event-processor/src/approvals-processor/approvals.processor.spec.ts index 6d9cee5..2c0a75c 100644 --- a/apps/axelar-event-processor/src/approvals-processor/approvals.processor.spec.ts +++ b/apps/axelar-event-processor/src/approvals-processor/approvals.processor.spec.ts @@ -225,7 +225,7 @@ describe('ApprovalsProcessorService', () => { task: { payload: BinaryUtils.hexToBase64('0123'), availableGasBalance: { - amount: '0', + amount: '100', }, message: { messageID: 'messageId', @@ -246,8 +246,8 @@ describe('ApprovalsProcessorService', () => { await service.handleNewTasksRaw(); - expect(messageApprovedRepository.create).toHaveBeenCalledTimes(1); - expect(messageApprovedRepository.create).toHaveBeenCalledWith({ + expect(messageApprovedRepository.createOrUpdate).toHaveBeenCalledTimes(1); + expect(messageApprovedRepository.createOrUpdate).toHaveBeenCalledWith({ sourceChain: 'ethereum', messageId: 'messageId', status: MessageApprovedStatus.PENDING, @@ -257,11 +257,12 @@ describe('ApprovalsProcessorService', () => { payload: Buffer.from('0123', 'hex'), retry: 0, taskItemId: 'UUID', + availableGasBalance: '100', }); expect(redisCacheService.set).toHaveBeenCalledTimes(1); }); - it('Should handle execute task duplicate in database', async () => { + it('Should handle execute task invalid gas token', async () => { axelarGmpApi.getTasks.mockReturnValueOnce( // @ts-ignore Promise.resolve({ @@ -270,16 +271,17 @@ describe('ApprovalsProcessorService', () => { { type: 'EXECUTE', task: { - payload: '0123', + payload: BinaryUtils.hexToBase64('0123'), availableGasBalance: { - amount: '0', + tokenID: 'other', + amount: '100', }, message: { messageID: 'messageId', destinationAddress: 'destinationAddress', sourceAddress: 'sourceAddress', sourceChain: 'ethereum', - payloadHash: '0234', + payloadHash: BinaryUtils.hexToBase64('0234'), }, } as ExecuteTask, id: 'UUID', @@ -291,11 +293,21 @@ describe('ApprovalsProcessorService', () => { }), ); - messageApprovedRepository.create.mockReturnValueOnce(Promise.resolve(null)); - await service.handleNewTasksRaw(); - expect(messageApprovedRepository.create).toHaveBeenCalledTimes(1); + expect(messageApprovedRepository.createOrUpdate).toHaveBeenCalledTimes(1); + expect(messageApprovedRepository.createOrUpdate).toHaveBeenCalledWith({ + sourceChain: 'ethereum', + messageId: 'messageId', + status: MessageApprovedStatus.PENDING, + sourceAddress: 'sourceAddress', + contractAddress: 'destinationAddress', + payloadHash: '0234', + payload: Buffer.from('0123', 'hex'), + retry: 0, + taskItemId: 'UUID', + availableGasBalance: '0', + }); expect(redisCacheService.set).toHaveBeenCalledTimes(1); }); diff --git a/apps/mvx-event-processor/src/cross-chain-transaction-processor/processors/gateway.processor.spec.ts b/apps/mvx-event-processor/src/cross-chain-transaction-processor/processors/gateway.processor.spec.ts index 9954efe..9431790 100644 --- a/apps/mvx-event-processor/src/cross-chain-transaction-processor/processors/gateway.processor.spec.ts +++ b/apps/mvx-event-processor/src/cross-chain-transaction-processor/processors/gateway.processor.spec.ts @@ -236,6 +236,7 @@ describe('GatewayProcessor', () => { createdAt: new Date(), successTimes: null, taskItemId: null, + availableGasBalance: '0', }; messageApprovedRepository.findBySourceChainAndMessageId.mockReturnValueOnce(Promise.resolve(messageApproved)); diff --git a/apps/mvx-event-processor/src/message-approved-processor/message-approved.processor.service.ts b/apps/mvx-event-processor/src/message-approved-processor/message-approved.processor.service.ts index 8df4526..5f01f63 100644 --- a/apps/mvx-event-processor/src/message-approved-processor/message-approved.processor.service.ts +++ b/apps/mvx-event-processor/src/message-approved-processor/message-approved.processor.service.ts @@ -17,15 +17,15 @@ import { TransactionsHelper } from '@mvx-monorepo/common/contracts/transactions. import { ApiConfigService, AxelarGmpApi } from '@mvx-monorepo/common'; import { ItsContract } from '@mvx-monorepo/common/contracts/its.contract'; import { Locker } from '@multiversx/sdk-nestjs-common'; -import { GasError } from '@mvx-monorepo/common/contracts/entities/gas.error'; +import { GasError, NotEnoughGasError } from '@mvx-monorepo/common/contracts/entities/gas.error'; import { CannotExecuteMessageEvent, - CannotExecuteMessageEventV2, + CannotExecuteMessageReason, Event, } from '@mvx-monorepo/common/api/entities/axelar.gmp.api'; import { AxiosError } from 'axios'; import { DecodingUtils } from '@mvx-monorepo/common/utils/decoding.utils'; -import { CONSTANTS } from '@mvx-monorepo/common/utils/constants.enum'; +import { FeeHelper } from '@mvx-monorepo/common/contracts/fee.helper'; // Support a max of 3 retries (mainly because some Interchain Token Service endpoints need to be called 2 times) const MAX_NUMBER_OF_RETRIES: number = 3; @@ -43,6 +43,7 @@ export class MessageApprovedProcessorService { private readonly transactionsHelper: TransactionsHelper, private readonly itsContract: ItsContract, private readonly axelarGmpApi: AxelarGmpApi, + private readonly feeHelper: FeeHelper, apiConfigService: ApiConfigService, ) { this.logger = new Logger(MessageApprovedProcessorService.name); @@ -70,7 +71,7 @@ export class MessageApprovedProcessorService { const entriesToUpdate: MessageApproved[] = []; const entriesWithTransactions: MessageApproved[] = []; for (const messageApproved of entries) { - if (messageApproved.retry === MAX_NUMBER_OF_RETRIES) { + if (messageApproved.retry >= MAX_NUMBER_OF_RETRIES) { await this.handleMessageApprovedFailed(messageApproved); entriesToUpdate.push(messageApproved); @@ -106,16 +107,20 @@ export class MessageApprovedProcessorService { entriesWithTransactions.push(messageApproved); } catch (e) { - this.logger.error( - `Could not build and sign execute transaction for ${messageApproved.sourceChain} ${messageApproved.messageId}`, - e, - ); + // In case of NotEnoughGasError, don't retry the transaction and mark it as failed instantly + if (e instanceof NotEnoughGasError) { + messageApproved.retry = MAX_NUMBER_OF_RETRIES; + messageApproved.status = MessageApprovedStatus.FAILED; - if (e instanceof GasError) { - messageApproved.retry += 1; + await this.handleMessageApprovedFailed(messageApproved, 'INSUFFICIENT_GAS'); entriesToUpdate.push(messageApproved); } else { + this.logger.error( + `Could not build and sign execute transaction for ${messageApproved.sourceChain} ${messageApproved.messageId}`, + e, + ); + throw e; } } @@ -160,8 +165,24 @@ export class MessageApprovedProcessorService { .withChainID(this.chainId) .buildTransaction(); - const gas = await this.transactionsHelper.getTransactionGas(transaction, messageApproved.retry); - transaction.setGasLimit(gas); + try { + const gas = await this.transactionsHelper.getTransactionGas(transaction, messageApproved.retry); + transaction.setGasLimit(gas); + + this.feeHelper.checkGasCost(gas, transaction.getValue(), transaction.getData(), messageApproved); + } catch (e) { + // In case the gas estimation fails, the transaction will fail on chain, but we will still send it + // for transparency with the full gas available, but don't try to retry it + if (e instanceof GasError) { + transaction.setGasLimit( + this.feeHelper.getGasLimitFromEgldFee(BigInt(messageApproved.availableGasBalance), transaction.getData()), + ); + + messageApproved.retry = MAX_NUMBER_OF_RETRIES - 1; + } else { + throw e; + } + } const signature = await this.walletSigner.sign(transaction.serializeForSigning()); transaction.applySignature(signature); @@ -201,24 +222,23 @@ export class MessageApprovedProcessorService { ); } - private async handleMessageApprovedFailed(messageApproved: MessageApproved) { + private async handleMessageApprovedFailed( + messageApproved: MessageApproved, + reason: CannotExecuteMessageReason = 'ERROR', + ) { this.logger.error( `Could not execute MessageApproved from ${messageApproved.sourceChain} with message id ${messageApproved.messageId} after ${messageApproved.retry} retries`, ); messageApproved.status = MessageApprovedStatus.FAILED; - const cannotExecuteEvent: CannotExecuteMessageEventV2 = { + const cannotExecuteEvent: CannotExecuteMessageEvent = { eventID: messageApproved.executeTxHash ? DecodingUtils.getEventId(messageApproved.executeTxHash, 0) : messageApproved.messageId, - messageID: messageApproved.messageId, - sourceChain: CONSTANTS.SOURCE_CHAIN_NAME, - reason: 'ERROR', + reason, details: '', - meta: { - taskItemID: messageApproved.taskItemId || '', - }, + taskItemID: messageApproved.taskItemId || '', }; try { diff --git a/apps/mvx-event-processor/test/message-approved.processor.e2e-spec.ts b/apps/mvx-event-processor/test/message-approved.processor.e2e-spec.ts index edb42ae..7402b34 100644 --- a/apps/mvx-event-processor/test/message-approved.processor.e2e-spec.ts +++ b/apps/mvx-event-processor/test/message-approved.processor.e2e-spec.ts @@ -63,6 +63,15 @@ describe('MessageApprovedProcessorService', () => { return Promise.resolve(null); }); + proxy.getNetworkConfig.mockImplementation((): Promise<any> => { + return Promise.resolve({ + MinGasPrice: 1000000000, + MinGasLimit: 50000, + GasPerDataByte: 1500, + GasPriceModifier: 0.01, + }); + }); + // Reset database & cache await prisma.messageApproved.deleteMany(); @@ -77,7 +86,7 @@ describe('MessageApprovedProcessorService', () => { }); const createMessageApproved = async (extraData: Partial<MessageApproved> = {}): Promise<MessageApproved> => { - const result = await messageApprovedRepository.create({ + await messageApprovedRepository.createOrUpdate({ sourceAddress: 'sourceAddress', messageId: 'messageId', status: MessageApprovedStatus.PENDING, @@ -89,14 +98,19 @@ describe('MessageApprovedProcessorService', () => { executeTxHash: null, updatedAt: new Date(), createdAt: new Date(), + availableGasBalance: '0', ...extraData, }); - if (!result) { - throw new Error('Can not create database entries'); - } - - return result; + // @ts-ignore + return await prisma.messageApproved.findUnique({ + where: { + sourceChain_messageId: { + sourceChain: extraData.sourceChain || 'ethereum', + messageId: extraData.messageId || 'messageId', + }, + }, + }); }; const assertArgs = (transaction: Transaction, entry: MessageApproved) => { @@ -110,12 +124,15 @@ describe('MessageApprovedProcessorService', () => { }; it('Should send execute transaction two initial', async () => { - const originalFirstEntry = await createMessageApproved(); + const originalFirstEntry = await createMessageApproved({ + availableGasBalance: '1200000000000000', + }); const originalSecondEntry = await createMessageApproved({ sourceChain: 'polygon', messageId: 'messageId2', sourceAddress: 'otherSourceAddress', payload: Buffer.from('otherPayload'), + availableGasBalance: '1200000000000000', }); proxy.sendTransactions.mockImplementation((transactions): Promise<string[]> => { @@ -176,6 +193,7 @@ describe('MessageApprovedProcessorService', () => { const originalFirstEntry = await createMessageApproved({ retry: 1, updatedAt: new Date(new Date().getTime() - 60_500), + availableGasBalance: '1200000000000000', }); const originalSecondEntry = await createMessageApproved({ sourceChain: 'polygon', @@ -185,11 +203,13 @@ describe('MessageApprovedProcessorService', () => { retry: 3, updatedAt: new Date(new Date().getTime() - 60_500), taskItemId: '0191ead2-2234-7310-b405-76e787415031', + availableGasBalance: '1200000000000000', }); // Entry will not be processed (updated too early) const originalThirdEntry = await createMessageApproved({ messageId: 'messageId3', retry: 1, + availableGasBalance: '1200000000000000', }); proxy.sendTransactions.mockImplementation((transactions): Promise<string[]> => { @@ -246,13 +266,9 @@ describe('MessageApprovedProcessorService', () => { expect(axelarGmpApi.postEvents.mock.lastCall[0][0]).toEqual({ type: 'CANNOT_EXECUTE_MESSAGE', eventID: originalSecondEntry.messageId, - messageID: originalSecondEntry.messageId, - sourceChain: 'multiversx', reason: 'ERROR', details: '', - meta: { - taskItemID: originalSecondEntry.taskItemId, - }, + taskItemID: originalSecondEntry.taskItemId, }); // Was not updated @@ -266,7 +282,9 @@ describe('MessageApprovedProcessorService', () => { }); it('Should send execute transaction not successfully sent', async () => { - const originalFirstEntry = await createMessageApproved(); + const originalFirstEntry = await createMessageApproved({ + availableGasBalance: '1200000000000000', + }); const originalSecondEntry = await createMessageApproved({ sourceChain: 'polygon', messageId: 'messageId2', @@ -274,6 +292,7 @@ describe('MessageApprovedProcessorService', () => { payload: Buffer.from('otherPayload'), retry: 2, updatedAt: new Date(new Date().getTime() - 60_500), + availableGasBalance: '1200000000000000', }); proxy.sendTransactions.mockImplementation((): Promise<string[]> => { @@ -324,10 +343,11 @@ describe('MessageApprovedProcessorService', () => { }); } - it('Should send execute transaction retry on gas failure', async () => { + it('Should send execute transaction do not retry on gas failure', async () => { const originalFirstEntry = await createMessageApproved({ retry: 1, updatedAt: new Date(new Date().getTime() - 60_500), + availableGasBalance: '1200000000000000', }); proxy.sendTransactions.mockImplementation((transactions): Promise<string[]> => { @@ -340,6 +360,36 @@ describe('MessageApprovedProcessorService', () => { await service.processPendingMessageApproved(); + expect(proxy.getAccount).toHaveBeenCalledTimes(1); + expect(proxy.doPostGeneric).toHaveBeenCalledTimes(1); + // Transaction is sent even though it will fail + expect(proxy.sendTransactions).toHaveBeenCalledTimes(1); + + // No contract call approved pending remained for now + expect(await messageApprovedRepository.findPending()).toEqual([]); + + // Expect entries in database updated + const firstEntry = await messageApprovedRepository.findBySourceChainAndMessageId( + originalFirstEntry.sourceChain, + originalFirstEntry.messageId, + ); + expect(firstEntry).toEqual({ + ...originalFirstEntry, + executeTxHash: '90d4f525856840a5c9c8115a30e87d823ac8261b298ca4ecb42f1b806fec363c', + retry: 3, + updatedAt: expect.any(Date), + }); + }); + + it('Should not send execute transaction if not enough gas', async () => { + const originalFirstEntry = await createMessageApproved({ + retry: 1, + updatedAt: new Date(new Date().getTime() - 60_500), + availableGasBalance: '300000000000000', // Not enough gas + }); + + await service.processPendingMessageApproved(); + expect(proxy.getAccount).toHaveBeenCalledTimes(1); expect(proxy.doPostGeneric).toHaveBeenCalledTimes(1); expect(proxy.sendTransactions).toHaveBeenCalledTimes(0); @@ -354,7 +404,37 @@ describe('MessageApprovedProcessorService', () => { ); expect(firstEntry).toEqual({ ...originalFirstEntry, - retry: 2, + status: 'FAILED', + retry: 3, + updatedAt: expect.any(Date), + }); + }); + + it('Should not send execute transaction if not enough gas negative', async () => { + const originalFirstEntry = await createMessageApproved({ + retry: 1, + updatedAt: new Date(new Date().getTime() - 60_500), + availableGasBalance: '-300000000000000', // Not enough gas negative + }); + + await service.processPendingMessageApproved(); + + expect(proxy.getAccount).toHaveBeenCalledTimes(1); + expect(proxy.doPostGeneric).toHaveBeenCalledTimes(1); + expect(proxy.sendTransactions).toHaveBeenCalledTimes(0); + + // No contract call approved pending remained for now + expect(await messageApprovedRepository.findPending()).toEqual([]); + + // Expect entries in database updated + const firstEntry = await messageApprovedRepository.findBySourceChainAndMessageId( + originalFirstEntry.sourceChain, + originalFirstEntry.messageId, + ); + expect(firstEntry).toEqual({ + ...originalFirstEntry, + status: 'FAILED', + retry: 3, updatedAt: expect.any(Date), }); }); @@ -366,12 +446,14 @@ describe('MessageApprovedProcessorService', () => { const originalItsExecuteOther = await createMessageApproved({ contractAddress, payload: Buffer.from(AbiCoder.defaultAbiCoder().encode(['uint256'], [0]).substring(2), 'hex'), + availableGasBalance: '1200000000000000', }); const originalItsExecute = await createMessageApproved({ contractAddress, sourceChain: 'polygon', sourceAddress: 'otherSourceAddress', payload: Buffer.from(AbiCoder.defaultAbiCoder().encode(['uint256'], [1]).substring(2), 'hex'), + availableGasBalance: '1200000000000000', }); mockProxySendTransactionsSuccess(); @@ -435,6 +517,7 @@ describe('MessageApprovedProcessorService', () => { sourceChain: 'polygon', sourceAddress: 'otherSourceAddress', payload: Buffer.from(AbiCoder.defaultAbiCoder().encode(['uint256'], [1]).substring(2), 'hex'), + availableGasBalance: '51200000000000000', // also contains 0.05 EGLD for ESDT issue }); mockProxySendTransactionsSuccess(); @@ -578,5 +661,35 @@ describe('MessageApprovedProcessorService', () => { successTimes: 1, }); }); + + it('Should send execute transaction deploy interchain token only deploy esdt not enough fee', async () => { + const originalItsExecute = await createMessageApproved({ + contractAddress, + sourceChain: 'polygon', + sourceAddress: 'otherSourceAddress', + payload: Buffer.from(AbiCoder.defaultAbiCoder().encode(['uint256'], [1]).substring(2), 'hex'), + retry: 1, + executeTxHash: '67b2b814e2ec9bdd08f57073f575ec95d160c76ec9ccd4d14395e7824b6b77cc', + successTimes: 1, + availableGasBalance: '1200000000000000', // not enough fee for paying 0.05 EGLD for ESDT issue + updatedAt: new Date(new Date().getTime() - 60_500), + }); + + // Process transaction for ESDT issue only + await service.processPendingMessageApproved(); + + expect(proxy.sendTransactions).toHaveBeenCalledTimes(0); + + const itsExecute = (await messageApprovedRepository.findBySourceChainAndMessageId( + originalItsExecute.sourceChain, + originalItsExecute.messageId, + )) as MessageApproved; + expect(itsExecute).toEqual({ + ...originalItsExecute, + retry: 3, + status: 'FAILED', + updatedAt: expect.any(Date), + }); + }); }); }); diff --git a/libs/common/src/config/api.config.service.ts b/libs/common/src/config/api.config.service.ts index 3233278..8c7ad69 100644 --- a/libs/common/src/config/api.config.service.ts +++ b/libs/common/src/config/api.config.service.ts @@ -157,4 +157,8 @@ export class ApiConfigService { getGatewayTimeout(): number { return this.configService.get<number>('GATEWAY_TIMEOUT') ?? 30_000; // 30 seconds default } + + isEnabledGasCheck(): boolean { + return this.configService.get<boolean>('ENABLE_GAS_CHECK') ?? false; + } } diff --git a/libs/common/src/contracts/contracts.module.ts b/libs/common/src/contracts/contracts.module.ts index 0ca4316..a7003bd 100644 --- a/libs/common/src/contracts/contracts.module.ts +++ b/libs/common/src/contracts/contracts.module.ts @@ -12,6 +12,7 @@ import { WegldSwapContract } from '@mvx-monorepo/common/contracts/wegld-swap.con import { ApiConfigService } from '@mvx-monorepo/common/config'; import { DynamicModuleUtils } from '@mvx-monorepo/common/utils'; import { ItsContract } from '@mvx-monorepo/common/contracts/its.contract'; +import { FeeHelper } from '@mvx-monorepo/common/contracts/fee.helper'; @Module({ imports: [DynamicModuleUtils.getCacheModule()], @@ -107,6 +108,7 @@ import { ItsContract } from '@mvx-monorepo/common/contracts/its.contract'; inject: [ApiConfigService, ResultsParser], }, TransactionsHelper, + FeeHelper, ], exports: [ GatewayContract, @@ -117,6 +119,7 @@ import { ItsContract } from '@mvx-monorepo/common/contracts/its.contract'; ProxyNetworkProvider, ApiNetworkProvider, TransactionsHelper, + FeeHelper, ], }) export class ContractsModule {} diff --git a/libs/common/src/contracts/entities/gas.error.ts b/libs/common/src/contracts/entities/gas.error.ts index 7552593..8d1678c 100644 --- a/libs/common/src/contracts/entities/gas.error.ts +++ b/libs/common/src/contracts/entities/gas.error.ts @@ -1 +1,3 @@ export class GasError extends Error {} + +export class NotEnoughGasError extends Error {} diff --git a/libs/common/src/contracts/fee.helper.spec.ts b/libs/common/src/contracts/fee.helper.spec.ts new file mode 100644 index 0000000..b61757d --- /dev/null +++ b/libs/common/src/contracts/fee.helper.spec.ts @@ -0,0 +1,199 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test } from '@nestjs/testing'; +import { TransactionPayload } from '@multiversx/sdk-core/out'; +import { ProxyNetworkProvider } from '@multiversx/sdk-network-providers/out'; +import { ApiConfigService } from '@mvx-monorepo/common'; +import { FeeHelper } from '@mvx-monorepo/common/contracts/fee.helper'; +import { NotEnoughGasError } from '@mvx-monorepo/common/contracts/entities/gas.error'; + +describe('FeeHelper', () => { + let proxy: DeepMocked<ProxyNetworkProvider>; + let apiConfigService: DeepMocked<ApiConfigService>; + + let feeHelper: FeeHelper; + + beforeEach(async () => { + proxy = createMock(); + apiConfigService = createMock(); + + const moduleRef = await Test.createTestingModule({ + providers: [FeeHelper], + }) + .useMocker((token) => { + if (token === ProxyNetworkProvider) { + return proxy; + } + + if (token === ApiConfigService) { + return apiConfigService; + } + + return null; + }) + .compile(); + + proxy.getNetworkConfig.mockImplementation((): Promise<any> => { + return Promise.resolve({ + MinGasPrice: 1000000000, + MinGasLimit: 50000, + GasPerDataByte: 1500, + GasPriceModifier: 0.01, + }); + }); + apiConfigService.isEnabledGasCheck.mockReturnValueOnce(true); + + feeHelper = moduleRef.get(FeeHelper); + }); + + describe('checkGasCost', () => { + it('Enough gas fee', () => { + try { + // @ts-ignore + feeHelper.checkGasCost(10_000_000, 0, TransactionPayload.fromEncoded('test'), { + availableGasBalance: '300000000000000', // 0.00003 EGLD + sourceChain: 'ethereum', + messageId: 'messageId', + }); + + expect(true).toEqual(true); + } catch (e) { + expect(false).toEqual(true); + } + }); + + it('Not enough gas fee', () => { + try { + // @ts-ignore + feeHelper.checkGasCost(10_000_000, 0, TransactionPayload.fromEncoded('test'), { + availableGasBalance: '100000000000000', // 0.00001 EGLD + sourceChain: 'ethereum', + messageId: 'messageId', + }); + + expect(false).toEqual(true); + } catch (e) { + expect(e).toEqual(new NotEnoughGasError()); + } + + try { + // @ts-ignore + feeHelper.checkGasCost(10_000_000, 0, TransactionPayload.fromEncoded('test'), { + availableGasBalance: '-100000000000000', // negative value + sourceChain: 'ethereum', + messageId: 'messageId', + }); + + expect(false).toEqual(true); + } catch (e) { + expect(e).toEqual(new NotEnoughGasError()); + } + }); + }); + + it('getGasLimitFromEgldFee', () => { + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(608621610000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWVANjE3NjYxNmM2MTZlNjM2ODY1MmQ2Njc1NmE2OUAzMDc4NjM0NTM0MzEzMDMzMzgzNjM3NDM0MzM0NDI2NjYyMzIzMzM4MzI0NTM2NDQzMDQyMzc0NjM4Mzg2NTM2NDUzMzQ2Mzg0NDM1MzYzMzQ0MzZAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAyMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMTI0ODY1NmM2YzZmMjA3NzZmNzI2YzY0MjA2MTY3NjE2OTZlMjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw', + ), + ), + ).toBe(BigInt(7712161)); + + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(726185000000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWVANjE3NjYxNmM2MTZlNjM2ODY1MmQ2Njc1NmE2OUAzMDc4NjM0NTM0MzEzMDMzMzgzNjM3NDM0MzM0NDI2NjYyMzIzMzM4MzI0NTM2NDQzMDQyMzc0NjM4Mzg2NTM2NDUzMzQ2Mzg0NDM1MzYzMzQ0MzZAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAyMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMTI0ODY1NmM2YzZmMjA3NzZmNzI2YzY0MjA2MTY3NjE2OTZlMjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw', + ), + ), + ).toBe(BigInt(19468500)); + + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(508621610000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWVANjE3NjYxNmM2MTZlNjM2ODY1MmQ2Njc1NmE2OUAzMDc4NjM0NTM0MzEzMDMzMzgzNjM3NDM0MzM0NDI2NjYyMzIzMzM4MzI0NTM2NDQzMDQyMzc0NjM4Mzg2NTM2NDUzMzQ2Mzg0NDM1MzYzMzQ0MzZAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAyMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMTI0ODY1NmM2YzZmMjA3NzZmNzI2YzY0MjA2MTY3NjE2OTZlMjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw', + ), + ), + ).toBe(BigInt(531500)); + + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(588621610000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWVANjE3NjYxNmM2MTZlNjM2ODY1MmQ2Njc1NmE2OUAzMDc4NjM0NTM0MzEzMDMzMzgzNjM3NDM0MzM0NDI2NjYyMzIzMzM4MzI0NTM2NDQzMDQyMzc0NjM4Mzg2NTM2NDUzMzQ2Mzg0NDM1MzYzMzQ0MzZAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAyMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMTI0ODY1NmM2YzZmMjA3NzZmNzI2YzY0MjA2MTY3NjE2OTZlMjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw', + ), + ), + ).toBe(BigInt(5712161)); + + // Larger data + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(588621610000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWVANjE3NjYxNmM2MTZlNjM2ODY1MmQ2Njc1NmE2OUAzMDc4NjM0NTM0MzEzMDMzMzgzNjM3NDM0MzM0NDI2NjYyMzIzMzM4MzI0NTM2NDQzMDQyMzc0NjM4Mzg2NTM2NDUzMzQ2Mzg0NDM1MzYzMzQ0MzZAMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAyMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMTI0ODY1NmM2YzZmMjA3NzZmNzI2YzY0MjA2MTY3NjE2OTZlMjEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA', + ), + ), + ).toBe(BigInt(4512161)); + + // Smaller data + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(588621610000000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWU=', + ), + ), + ).toBe(BigInt(51762161)); + + expect( + feeHelper.getGasLimitFromEgldFee( + BigInt(-1000), + TransactionPayload.fromEncoded( + 'c2V0UmVtb3RlVmFsdWU=', + ), + ), + ).toBe(BigInt(71000)); + }); + + it('getEgldFeeFromGasLimit', () => { + expect( + feeHelper.getEgldFeeFromGasLimit( + BigInt(8243661), + BigInt(321) + ), + ).toBe(BigInt(613936610000000)); + + expect( + feeHelper.getEgldFeeFromGasLimit( + BigInt(6000000), + BigInt(321) + ), + ).toBe(BigInt(591500000000000)); + + + expect( + feeHelper.getEgldFeeFromGasLimit( + BigInt(20000000), + BigInt(321) + ), + ).toBe(BigInt(731500000000000)); + + // Larger data + expect( + feeHelper.getEgldFeeFromGasLimit( + BigInt(20000000), + BigInt(350) + ), + ).toBe(BigInt(775000000000000)); + + // Smaller data + expect( + feeHelper.getEgldFeeFromGasLimit( + BigInt(20000000), + BigInt(300) + ), + ).toBe(BigInt(700000000000000)); + }); +}); diff --git a/libs/common/src/contracts/fee.helper.ts b/libs/common/src/contracts/fee.helper.ts new file mode 100644 index 0000000..4b54864 --- /dev/null +++ b/libs/common/src/contracts/fee.helper.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ProxyNetworkProvider } from '@multiversx/sdk-network-providers/out'; +import { ApiConfigService } from '@mvx-monorepo/common'; +import { ITransactionPayload, ITransactionValue } from '@multiversx/sdk-core/out'; +import { MessageApproved } from '@prisma/client'; +import { NotEnoughGasError } from '@mvx-monorepo/common/contracts/entities/gas.error'; + +@Injectable() +export class FeeHelper implements OnModuleInit { + private readonly logger: Logger; + + private readonly isEnabledGasCheck: boolean; + private minGasPrice: bigint = BigInt(1000000000); + private minGasLimit: bigint = BigInt(50000); + private gasPerDataByte: bigint = BigInt(1500); + private gasPriceModifierInverted: bigint = BigInt(100); + + constructor( + private readonly proxy: ProxyNetworkProvider, + apiConfigService: ApiConfigService, + ) { + this.logger = new Logger(FeeHelper.name); + + this.isEnabledGasCheck = apiConfigService.isEnabledGasCheck(); + } + + async onModuleInit() { + const config = await this.proxy.getNetworkConfig(); + + this.minGasPrice = BigInt(config.MinGasPrice); + this.minGasLimit = BigInt(config.MinGasLimit); + this.gasPerDataByte = BigInt(config.GasPerDataByte); + this.gasPriceModifierInverted = BigInt(10_000 * config.GasPriceModifier); + } + + public checkGasCost( + gas: number, + value: ITransactionValue, + data: ITransactionPayload, + messageApproved: MessageApproved, + ) { + const gasFee = this.getEgldFeeFromGasLimit(BigInt(gas), BigInt(data.length())); + const egldValue = BigInt(value.toString()); + const total = gasFee + egldValue; + + // Also take into account value in case of ITS + if (total <= BigInt(messageApproved.availableGasBalance)) { + return; + } + + if (!this.isEnabledGasCheck) { + this.logger.warn( + `[GAS CHECK NOT ENABLED] Not enough gas to execute transaction ${messageApproved.sourceChain} ${messageApproved.messageId} BUT it will be executed anyway. Needed ${total} EGLD but only have ${messageApproved.availableGasBalance} EGLD`, + ); + + return; + } + + this.logger.warn( + `[GAS CHECK ENABLED] Not enough gas to execute transaction ${messageApproved.sourceChain} ${messageApproved.messageId}. Needed ${total} EGLD but only have ${messageApproved.availableGasBalance} EGLD`, + ); + + throw new NotEnoughGasError(); + } + + public getGasLimitFromEgldFee(availableGasBalance: bigint, data: ITransactionPayload): bigint { + const gasLimit1 = this.minGasLimit + this.gasPerDataByte * BigInt(data.length()); + + // Use data gas limit in this case + if (availableGasBalance < gasLimit1 * this.minGasPrice) { + return gasLimit1; + } + + return ((availableGasBalance - gasLimit1 * this.minGasPrice) * this.gasPriceModifierInverted) / this.minGasPrice; + } + + public getEgldFeeFromGasLimit(gasLimit2: bigint, dataLength: bigint): bigint { + const gasLimit1 = this.minGasLimit + this.gasPerDataByte * dataLength; + + return gasLimit1 * this.minGasPrice + (gasLimit2 * this.minGasPrice) / this.gasPriceModifierInverted; + } +} diff --git a/libs/common/src/database/repository/message-approved.repository.ts b/libs/common/src/database/repository/message-approved.repository.ts index 60d8396..53111a9 100644 --- a/libs/common/src/database/repository/message-approved.repository.ts +++ b/libs/common/src/database/repository/message-approved.repository.ts @@ -6,21 +6,18 @@ import { MessageApproved, MessageApprovedStatus, Prisma } from '@prisma/client'; export class MessageApprovedRepository { constructor(private readonly prisma: PrismaService) {} - async create(data: Prisma.MessageApprovedCreateInput): Promise<MessageApproved | null> { - try { - return await this.prisma.messageApproved.create({ - data, - }); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - // Unique constraint fails - if (e.code === 'P2002') { - return null; - } - } - - throw e; - } + async createOrUpdate(data: Prisma.MessageApprovedCreateInput) { + await this.prisma.messageApproved.upsert({ + where: { + sourceChain_messageId: { + sourceChain: data.sourceChain, + messageId: data.messageId, + }, + }, + update: data, + create: data, + select: null, + }); } findPending(page: number = 0, take: number = 10): Promise<MessageApproved[] | null> { diff --git a/prisma/migrations/20241118081638_add_available_gas_balance/migration.sql b/prisma/migrations/20241118081638_add_available_gas_balance/migration.sql new file mode 100644 index 0000000..5a830cd --- /dev/null +++ b/prisma/migrations/20241118081638_add_available_gas_balance/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "MessageApproved" ADD COLUMN "availableGasBalance" VARCHAR(255) NOT NULL DEFAULT '0'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f55510..fb2e42b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,19 +11,20 @@ datasource db { } model MessageApproved { - sourceChain String @db.VarChar(255) - messageId String @db.VarChar(255) - status MessageApprovedStatus - sourceAddress String @db.VarChar(255) - contractAddress String @db.VarChar(62) - payloadHash String @db.VarChar(64) - payload Bytes - executeTxHash String? @db.VarChar(64) - retry Int @db.SmallInt - createdAt DateTime @default(now()) @db.Timestamp(6) - updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) - successTimes Int? @db.SmallInt - taskItemId String? @db.Uuid + sourceChain String @db.VarChar(255) + messageId String @db.VarChar(255) + status MessageApprovedStatus + sourceAddress String @db.VarChar(255) + contractAddress String @db.VarChar(62) + payloadHash String @db.VarChar(64) + payload Bytes + executeTxHash String? @db.VarChar(64) + retry Int @db.SmallInt + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) + successTimes Int? @db.SmallInt + taskItemId String? @db.Uuid + availableGasBalance String @default("0") @db.VarChar(255) @@id([sourceChain, messageId]) }