diff --git a/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts b/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts index 6d0136bfdf8..3943a524178 100644 --- a/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts +++ b/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts @@ -21,7 +21,7 @@ import { CommandVerifyContext, VerificationResult, } from '../../../../state_machine/types'; -import { CCMsg, MessageRecoveryParams } from '../../types'; +import { CCMsg, CrossChainMessageContext, MessageRecoveryParams } from '../../types'; import { BaseInteroperabilityCommand } from '../../base_interoperability_command'; import { MainchainInteroperabilityInternalMethod } from '../internal_method'; import { verifyMessageRecovery, swapReceivingAndSendingChainIDs } from '../../utils'; @@ -31,6 +31,11 @@ import { BaseCCMethod } from '../../base_cc_method'; import { TerminatedOutboxAccount, TerminatedOutboxStore } from '../../stores/terminated_outbox'; import { OwnChainAccountStore } from '../../stores/own_chain_account'; import { ChainAccountStore, ChainStatus } from '../../stores/chain_account'; +import { + CCMProcessedCode, + CcmProcessedEvent, + CCMProcessedResult, +} from '../../events/ccm_processed'; export class MainchainMessageRecoveryCommand extends BaseInteroperabilityCommand { public schema = messageRecoveryParamsSchema; @@ -156,4 +161,154 @@ export class MainchainMessageRecoveryCommand extends BaseInteroperabilityCommand await this.internalMethod.addToOutbox(context, ccmChainId, newCcm); } } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + private async _applyRecovery(context: CrossChainMessageContext): Promise { + const { logger } = context; + const ccmID = utils.hash(codec.encode(ccmSchema, context.ccm)); + const ccm: CCMsg = { + ...context.ccm, + status: CCMStatusCode.RECOVERED, + sendingChainID: context.ccm.receivingChainID, + receivingChainID: context.ccm.sendingChainID, + }; + + try { + for (const [module, method] of this.interoperableCCMethods.entries()) { + if (method.verifyCrossChainMessage) { + logger.debug( + { + moduleName: module, + commandName: ccm.crossChainCommand, + ccmID: ccmID.toString('hex'), + }, + 'Execute verifyCrossChainMessage', + ); + await method.verifyCrossChainMessage(context); + } + } + } catch (error) { + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.INVALID_CCM_VERIFY_CCM_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }); + return; + } + const commands = this.ccCommands.get(ccm.module); + if (!commands) { + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.MODULE_NOT_SUPPORTED, + result: CCMProcessedResult.DISCARDED, + }); + return; + } + const command = commands.find(com => com.name === ccm.crossChainCommand); + if (!command) { + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.CROSS_CHAIN_COMMAND_NOT_SUPPORTED, + result: CCMProcessedResult.DISCARDED, + }); + return; + } + if (command.verify) { + try { + await command.verify(context); + } catch (error) { + logger.info( + { err: error as Error, moduleName: ccm.module, commandName: ccm.crossChainCommand }, + 'Fail to verify cross chain command.', + ); + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.INVALID_CCM_VERIFY_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }); + return; + } + } + const baseEventSnapshotID = context.eventQueue.createSnapshot(); + const baseStateSnapshotID = context.stateStore.createSnapshot(); + + try { + for (const [module, method] of this.interoperableCCMethods.entries()) { + if (method.beforeCrossChainCommandExecute) { + logger.debug( + { + moduleName: module, + commandName: ccm.crossChainCommand, + ccmID: ccmID.toString('hex'), + }, + 'Execute beforeCrossChainCommandExecute', + ); + await method.beforeCrossChainCommandExecute(context); + } + } + } catch (error) { + context.eventQueue.restoreSnapshot(baseEventSnapshotID); + context.stateStore.restoreSnapshot(baseStateSnapshotID); + logger.info( + { err: error as Error, moduleName: ccm.module, commandName: ccm.crossChainCommand }, + 'Fail to execute beforeCrossChainCommandExecute.', + ); + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.INVALID_CCM_BEFORE_CCC_EXECUTION_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }); + return; + } + + const execEventSnapshotID = context.eventQueue.createSnapshot(); + const execStateSnapshotID = context.stateStore.createSnapshot(); + + try { + const params = command.schema ? codec.decode(command.schema, ccm.params) : {}; + await command.execute({ ...context, params }); + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.SUCCESS, + result: CCMProcessedResult.APPLIED, + }); + } catch (error) { + context.eventQueue.restoreSnapshot(execEventSnapshotID); + context.stateStore.restoreSnapshot(execStateSnapshotID); + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.FAILED_CCM, + result: CCMProcessedResult.DISCARDED, + }); + } + + try { + for (const [module, method] of this.interoperableCCMethods.entries()) { + if (method.afterCrossChainCommandExecute) { + logger.debug( + { + moduleName: module, + commandName: ccm.crossChainCommand, + ccmID: ccmID.toString('hex'), + }, + 'Execute afterCrossChainCommandExecute', + ); + await method.afterCrossChainCommandExecute(context); + } + } + } catch (error) { + context.eventQueue.restoreSnapshot(baseEventSnapshotID); + context.stateStore.restoreSnapshot(baseStateSnapshotID); + logger.info( + { err: error as Error, moduleName: module, commandName: ccm.crossChainCommand }, + 'Fail to execute afterCrossChainCommandExecute', + ); + this.events.get(CcmProcessedEvent).log(context, ccm.sendingChainID, ccm.receivingChainID, { + ccmID, + code: CCMProcessedCode.INVALID_CCM_AFTER_CCC_EXECUTION_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }); + } + } } diff --git a/framework/test/unit/modules/interoperability/mainchain/commands/message_recovery.spec.ts b/framework/test/unit/modules/interoperability/mainchain/commands/message_recovery.spec.ts index 82e530b64af..613615fffbf 100644 --- a/framework/test/unit/modules/interoperability/mainchain/commands/message_recovery.spec.ts +++ b/framework/test/unit/modules/interoperability/mainchain/commands/message_recovery.spec.ts @@ -11,6 +11,7 @@ * * Removal or modification of this copyright notice is prohibited. */ +/* eslint-disable max-classes-per-file */ import { when } from 'jest-when'; import { codec } from '@liskhq/lisk-codec'; @@ -26,19 +27,22 @@ import { MODULE_NAME_INTEROPERABILITY, } from '../../../../../../src/modules/interoperability/constants'; import { MainchainMessageRecoveryCommand } from '../../../../../../src/modules/interoperability/mainchain/commands/message_recovery'; -import { MainchainInteroperabilityInternalMethod } from '../../../../../../src/modules/interoperability/mainchain/internal_method'; import { ccmSchema, messageRecoveryParamsSchema, } from '../../../../../../src/modules/interoperability/schemas'; -import { CCMsg, MessageRecoveryParams } from '../../../../../../src/modules/interoperability/types'; +import { + CCMsg, + CrossChainMessageContext, + MessageRecoveryParams, +} from '../../../../../../src/modules/interoperability/types'; import { CommandVerifyContext, VerifyStatus } from '../../../../../../src/state_machine/types'; -import { createTransactionContext } from '../../../../../../src/testing'; +import { + createCrossChainMessageContext, + createTransactionContext, +} from '../../../../../../src/testing'; import { swapReceivingAndSendingChainIDs } from '../../../../../../src/modules/interoperability/utils'; import { TransactionContext } from '../../../../../../src/state_machine'; -import { Mocked } from '../../../../../utils/types'; -// import { PrefixedStateReadWriter } from '../../../../../../src/state_machine/prefixed_state_read_writer'; -// import { InMemoryPrefixedStateDB } from '../../../../../../src/testing/in_memory_prefixed_state'; import { TerminatedOutboxStore } from '../../../../../../src/modules/interoperability/stores/terminated_outbox'; import { createStoreGetter } from '../../../../../../src/testing/utils'; import { @@ -46,19 +50,36 @@ import { ChainStatus, } from '../../../../../../src/modules/interoperability/stores/chain_account'; import { OwnChainAccountStore } from '../../../../../../src/modules/interoperability/stores/own_chain_account'; +import { + CCMProcessedCode, + CcmProcessedEvent, + CCMProcessedResult, +} from '../../../../../../src/modules/interoperability/events/ccm_processed'; +import { CcmSendSuccessEvent } from '../../../../../../src/modules/interoperability/events/ccm_send_success'; describe('Mainchain MessageRecoveryCommand', () => { - const interopMod = new MainchainInteroperabilityModule(); + const interopModule = new MainchainInteroperabilityModule(); + + let command: MainchainMessageRecoveryCommand; + + beforeEach(() => { + command = new MainchainMessageRecoveryCommand( + interopModule.stores, + interopModule.events, + new Map(), + new Map(), + { + addToOutbox: jest.fn(), + isLive: jest.fn().mockResolvedValue(true), + } as never, + ); + }); describe('verify', () => { const LEAF_PREFIX = Buffer.from('00', 'hex'); // let stateStore: PrefixedStateReadWriter; - let terminatedOutboxSubstore: TerminatedOutboxStore; - let messageRecoveryCommand: MainchainMessageRecoveryCommand; let commandVerifyContext: CommandVerifyContext; - let interoperableCCMethods: Map; - let ccCommands: Map; let transaction: Transaction; let transactionParams: MessageRecoveryParams; let encodedTransactionParams: Buffer; @@ -74,18 +95,6 @@ describe('Mainchain MessageRecoveryCommand', () => { let generatedProof: any; beforeEach(async () => { - interoperableCCMethods = new Map(); - ccCommands = new Map(); - - terminatedOutboxSubstore = interopMod.stores.get(TerminatedOutboxStore); - messageRecoveryCommand = new MainchainMessageRecoveryCommand( - interopMod.stores, - interopMod.events, - interoperableCCMethods, - ccCommands, - interopMod['internalMethod'], - ); - ccms = [ { nonce: BigInt(0), @@ -151,39 +160,34 @@ describe('Mainchain MessageRecoveryCommand', () => { transaction, }).createCommandVerifyContext(messageRecoveryParamsSchema); - await terminatedOutboxSubstore.set( - createStoreGetter(commandVerifyContext.stateStore as any), - chainID, - { + await interopModule.stores + .get(TerminatedOutboxStore) + .set(createStoreGetter(commandVerifyContext.stateStore as any), chainID, { outboxRoot, outboxSize: terminatedChainOutboxSize, partnerChainInboxSize: 1, - }, - ); + }); }); it('should return error if the sidechain outbox root is not valid', async () => { - await terminatedOutboxSubstore.set( - createStoreGetter(commandVerifyContext.stateStore as any), - chainID, - { + await interopModule.stores + .get(TerminatedOutboxStore) + .set(createStoreGetter(commandVerifyContext.stateStore as any), chainID, { outboxRoot: utils.getRandomBytes(32), outboxSize: terminatedChainOutboxSize, partnerChainInboxSize: 1, - }, - ); - const result = await messageRecoveryCommand.verify(commandVerifyContext); + }); + const result = await command.verify(commandVerifyContext); expect(result.status).toBe(VerifyStatus.FAIL); expect(result.error?.message).toInclude(`The sidechain outbox root is not valid`); }); it('should return error if terminated outbox account does not exist', async () => { - await terminatedOutboxSubstore.del( - createStoreGetter(commandVerifyContext.stateStore as any), - chainID, - ); - const result = await messageRecoveryCommand.verify(commandVerifyContext); + await interopModule.stores + .get(TerminatedOutboxStore) + .del(createStoreGetter(commandVerifyContext.stateStore as any), chainID); + const result = await command.verify(commandVerifyContext); expect(result.status).toBe(VerifyStatus.FAIL); expect(result.error?.message).toInclude(`Terminated outbox account does not exist`); @@ -205,17 +209,15 @@ describe('Mainchain MessageRecoveryCommand', () => { transaction, }).createCommandVerifyContext(messageRecoveryParamsSchema); - await terminatedOutboxSubstore.set( - createStoreGetter(commandVerifyContext.stateStore as any), - chainID, - { + await interopModule.stores + .get(TerminatedOutboxStore) + .set(createStoreGetter(commandVerifyContext.stateStore as any), chainID, { outboxRoot, outboxSize: terminatedChainOutboxSize, partnerChainInboxSize: 1, - }, - ); + }); - const result = await messageRecoveryCommand.verify(commandVerifyContext); + const result = await command.verify(commandVerifyContext); expect(result.status).toBe(VerifyStatus.FAIL); expect(result.error?.message).toInclude(`Cross chain messages are still pending`); @@ -284,17 +286,15 @@ describe('Mainchain MessageRecoveryCommand', () => { commandVerifyContext = createTransactionContext({ transaction, }).createCommandVerifyContext(messageRecoveryParamsSchema); - await terminatedOutboxSubstore.set( - createStoreGetter(commandVerifyContext.stateStore as any), - chainID, - { + await interopModule.stores + .get(TerminatedOutboxStore) + .set(createStoreGetter(commandVerifyContext.stateStore as any), chainID, { outboxRoot, outboxSize: terminatedChainOutboxSize, partnerChainInboxSize: 1, - }, - ); + }); - const result = await messageRecoveryCommand.verify(commandVerifyContext); + const result = await command.verify(commandVerifyContext); expect(result.status).toBe(VerifyStatus.FAIL); expect(result.error?.message).toInclude( @@ -303,7 +303,7 @@ describe('Mainchain MessageRecoveryCommand', () => { }); it('should return status OK for valid params', async () => { - const result = await messageRecoveryCommand.verify(commandVerifyContext); + const result = await command.verify(commandVerifyContext); expect(result.status).toBe(VerifyStatus.OK); }); @@ -343,8 +343,6 @@ describe('Mainchain MessageRecoveryCommand', () => { return commandExecuteContext; }; - type StoreMock = Mocked; - const chainAccountStoreMock = { get: jest.fn(), set: jest.fn(), @@ -360,34 +358,14 @@ describe('Mainchain MessageRecoveryCommand', () => { set: jest.fn(), has: jest.fn(), }; - let messageRecoveryCommand: MainchainMessageRecoveryCommand; let commandExecuteContext: CommandExecuteContext; - let interoperableCCMethods: Map; - let ccCommands: Map; let transaction: Transaction; let transactionParams: MessageRecoveryParams; let encodedTransactionParams: Buffer; let transactionContext: TransactionContext; - let storeMock: StoreMock; let ccms: CCMsg[]; beforeEach(() => { - interoperableCCMethods = new Map(); - ccCommands = new Map(); - storeMock = { - addToOutbox: jest.fn(), - isLive: jest.fn().mockResolvedValue(true), - }; - interopMod['internalMethod'] = storeMock as any; - - messageRecoveryCommand = new MainchainMessageRecoveryCommand( - interopMod.stores, - interopMod.events, - interoperableCCMethods, - ccCommands, - interopMod['internalMethod'], - ); - ccms = [ { nonce: BigInt(0), @@ -413,7 +391,7 @@ describe('Mainchain MessageRecoveryCommand', () => { commandExecuteContext = createCommandExecuteContext(ccms); - interopMod.stores.get(OwnChainAccountStore).get = ownChainAccountStoreMock.get; + interopModule.stores.get(OwnChainAccountStore).get = ownChainAccountStoreMock.get; ownChainAccountStoreMock.get.mockResolvedValue({ name: `mainchain`, chainID: utils.intToBuffer(0, 4), @@ -426,9 +404,9 @@ describe('Mainchain MessageRecoveryCommand', () => { let chainID; - interopMod.stores.register(ChainAccountStore, chainAccountStoreMock as never); - interopMod.stores.register(OwnChainAccountStore, ownChainAccountStoreMock as never); - interopMod.stores.register(TerminatedOutboxStore, terminatedOutboxAccountMock as never); + interopModule.stores.register(ChainAccountStore, chainAccountStoreMock as never); + interopModule.stores.register(OwnChainAccountStore, ownChainAccountStoreMock as never); + interopModule.stores.register(TerminatedOutboxStore, terminatedOutboxAccountMock as never); for (const ccm of ccms) { chainID = ccm.sendingChainID; @@ -447,7 +425,7 @@ describe('Mainchain MessageRecoveryCommand', () => { chainID = transactionParams.chainID; - interopMod.stores.get(TerminatedOutboxStore).get = terminatedOutboxAccountMock.get; + interopModule.stores.get(TerminatedOutboxStore).get = terminatedOutboxAccountMock.get; terminatedOutboxAccountMock.get.mockResolvedValue({ outboxRoot: utils.getRandomBytes(32), outboxSize: 1, @@ -460,7 +438,7 @@ describe('Mainchain MessageRecoveryCommand', () => { it('should successfully process recovery transaction', async () => { // Act chainAccountStoreMock.has.mockResolvedValue(true); - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); expect.assertions(ccms.length + 1); { @@ -482,7 +460,7 @@ describe('Mainchain MessageRecoveryCommand', () => { // Assign const chainID = ccm.sendingChainID; // Assert - expect(storeMock.addToOutbox).toHaveBeenCalledWith( + expect(command['internalMethod'].addToOutbox).toHaveBeenCalledWith( expect.anything(), chainID, swapReceivingAndSendingChainIDs(ccm), @@ -499,10 +477,10 @@ describe('Mainchain MessageRecoveryCommand', () => { MODULE_NAME_INTEROPERABILITY, } as unknown) as BaseCCMethod; - interoperableCCMethods.set(MODULE_NAME_INTEROPERABILITY, method); + command['interoperableCCMethods'].set(MODULE_NAME_INTEROPERABILITY, method); // Assert - await expect(messageRecoveryCommand.execute(commandExecuteContext)).rejects.toThrow( + await expect(command.execute(commandExecuteContext)).rejects.toThrow( 'beforeRecoverCCM Error', ); }); @@ -514,7 +492,7 @@ describe('Mainchain MessageRecoveryCommand', () => { when(terminatedOutboxAccountMock.has).calledWith(chainID).mockResolvedValue(false); // Assert - await expect(messageRecoveryCommand.execute(commandExecuteContext)).rejects.toThrow( + await expect(command.execute(commandExecuteContext)).rejects.toThrow( 'Terminated outbox account does not exist', ); }); @@ -523,12 +501,12 @@ describe('Mainchain MessageRecoveryCommand', () => { // Assign & Arrange & Act chainAccountStoreMock.has.mockResolvedValue(false); - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length); for (const _ of ccms) { - expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + expect(command['internalMethod'].addToOutbox).not.toHaveBeenCalled(); } }); @@ -537,15 +515,17 @@ describe('Mainchain MessageRecoveryCommand', () => { for (const ccm of ccms) { const chainID = ccm.sendingChainID; - when(storeMock.isLive).calledWith(chainID).mockResolvedValue(false); + when(command['internalMethod'].isLive as never) + .calledWith(chainID) + .mockResolvedValue(false as never); } - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length); for (const _ of ccms) { - expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + expect(command['internalMethod'].addToOutbox).not.toHaveBeenCalled(); } }); @@ -561,12 +541,12 @@ describe('Mainchain MessageRecoveryCommand', () => { } as any); } - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length); for (const _ of ccms) { - expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + expect(command['internalMethod'].addToOutbox).not.toHaveBeenCalled(); } }); @@ -582,7 +562,7 @@ describe('Mainchain MessageRecoveryCommand', () => { ); for (const ccm of ccms) { - ccCommands.set(ccm.module, ([ + command['ccCommands'].set(ccm.module, ([ { name: ccm.crossChainCommand, execute: jest.fn(), @@ -593,14 +573,14 @@ describe('Mainchain MessageRecoveryCommand', () => { const ccmsWithSwappedChainIds = ccms.map(swapReceivingAndSendingChainIDs); // Act - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length); for (const ccm of ccmsWithSwappedChainIds) { - const commands = ccCommands.get(ccm.module) as BaseCCCommand[]; - const command = commands.find(cmd => cmd.name === ccm.crossChainCommand) as BaseCCCommand; - expect(command.execute).toHaveBeenCalled(); + const commands = command['ccCommands'].get(ccm.module) as BaseCCCommand[]; + const ccCommand = commands.find(cmd => cmd.name === ccm.crossChainCommand) as BaseCCCommand; + expect(ccCommand.execute).toHaveBeenCalled(); } }); @@ -614,7 +594,7 @@ describe('Mainchain MessageRecoveryCommand', () => { ); for (const ccm of ccms) { - ccCommands.set(ccm.module, ([ + command['ccCommands'].set(ccm.module, ([ { ID: utils.intToBuffer(500, 4), execute: jest.fn(), @@ -625,13 +605,13 @@ describe('Mainchain MessageRecoveryCommand', () => { BaseCCCommand.prototype.execute = jest.fn(); // Act - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length * 2); for (const _ of ccms) { expect(BaseCCCommand.prototype.execute).not.toHaveBeenCalled(); - expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + expect(command['internalMethod'].addToOutbox).not.toHaveBeenCalled(); } }); @@ -647,14 +627,255 @@ describe('Mainchain MessageRecoveryCommand', () => { BaseCCCommand.prototype.execute = jest.fn(); // Act - await messageRecoveryCommand.execute(commandExecuteContext); + await command.execute(commandExecuteContext); // Assert expect.assertions(ccms.length * 2); for (const _ of ccms) { expect(BaseCCCommand.prototype.execute).not.toHaveBeenCalled(); - expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + expect(command['internalMethod'].addToOutbox).not.toHaveBeenCalled(); } }); }); + + describe('_applyRecovery', () => { + const defaultCCM = { + nonce: BigInt(0), + module: 'token', + crossChainCommand: 'crossChainTransfer', + sendingChainID: Buffer.from([0, 0, 2, 0]), + receivingChainID: Buffer.from([0, 0, 3, 0]), + fee: BigInt(20000), + status: 0, + params: Buffer.alloc(0), + }; + + let context: CrossChainMessageContext; + + beforeEach(() => { + command['interoperableCCMethods'].set( + 'token', + new (class TokenMethod extends BaseCCMethod { + public verifyCrossChainMessage = jest.fn(); + public beforeCrossChainCommandExecute = jest.fn(); + public afterCrossChainCommandExecute = jest.fn(); + })(interopModule.stores, interopModule.events), + ); + command['ccCommands'].set('token', [ + new (class CrossChainTransfer extends BaseCCCommand { + public verify = jest.fn(); + public execute = jest.fn(); + })(interopModule.stores, interopModule.events), + ]); + jest.spyOn(command['events'].get(CcmProcessedEvent), 'log'); + jest.spyOn(command['events'].get(CcmSendSuccessEvent), 'log'); + context = createCrossChainMessageContext({ + ccm: defaultCCM, + }); + }); + + it('should log event when verifyCrossChainMessage fails', async () => { + ((command['interoperableCCMethods'].get('token') as BaseCCMethod) + .verifyCrossChainMessage as jest.Mock).mockRejectedValue('error'); + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.INVALID_CCM_VERIFY_CCM_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should log event if the module is not registered', async () => { + context = createCrossChainMessageContext({ + ccm: { + ...defaultCCM, + module: 'nonExisting', + }, + }); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.MODULE_NOT_SUPPORTED, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should log event if the command is not registered', async () => { + context = createCrossChainMessageContext({ + ccm: { + ...defaultCCM, + crossChainCommand: 'nonExisting', + }, + }); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.CROSS_CHAIN_COMMAND_NOT_SUPPORTED, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should log event when command verify fails', async () => { + (((command['ccCommands'].get(defaultCCM.module) as BaseCCCommand[]).find( + com => com.name === defaultCCM.crossChainCommand, + ) as BaseCCCommand).verify as jest.Mock).mockRejectedValue('error'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.INVALID_CCM_VERIFY_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should log event when command beforeCrossChainCommandExecute fails', async () => { + ((command['interoperableCCMethods'].get('token') as BaseCCMethod) + .beforeCrossChainCommandExecute as jest.Mock).mockRejectedValue('error'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.INVALID_CCM_BEFORE_CCC_EXECUTION_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should revert to the original state/event when command beforeCrossChainCommandExecute fails', async () => { + ((command['interoperableCCMethods'].get('token') as BaseCCMethod) + .beforeCrossChainCommandExecute as jest.Mock).mockRejectedValue('error'); + jest.spyOn(context.eventQueue, 'createSnapshot').mockReturnValue(99); + jest.spyOn(context.stateStore, 'createSnapshot').mockReturnValue(10); + jest.spyOn(context.eventQueue, 'restoreSnapshot'); + jest.spyOn(context.stateStore, 'restoreSnapshot'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.restoreSnapshot).toHaveBeenCalledWith(99); + expect(context.stateStore.restoreSnapshot).toHaveBeenCalledWith(10); + }); + + it('should log event and restore the state/event before calling execute when execute fails', async () => { + (((command['ccCommands'].get(defaultCCM.module) as BaseCCCommand[]).find( + com => com.name === defaultCCM.crossChainCommand, + ) as BaseCCCommand).execute as jest.Mock).mockRejectedValue('error'); + let eventQueueCount = 0; + let stateStoreCount = 0; + jest.spyOn(context.eventQueue, 'createSnapshot').mockImplementation(() => { + eventQueueCount += 1; + return eventQueueCount; + }); + jest.spyOn(context.stateStore, 'createSnapshot').mockImplementation(() => { + stateStoreCount += 1; + return stateStoreCount; + }); + jest.spyOn(context.eventQueue, 'restoreSnapshot'); + jest.spyOn(context.stateStore, 'restoreSnapshot'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.restoreSnapshot).toHaveBeenCalledWith(2); + expect(context.stateStore.restoreSnapshot).toHaveBeenCalledWith(2); + expect( + (command['interoperableCCMethods'].get('token') as BaseCCMethod) + .afterCrossChainCommandExecute as jest.Mock, + ).toHaveBeenCalledTimes(1); + }); + + it('should log event when command afterCrossChainCommandExecute fails', async () => { + ((command['interoperableCCMethods'].get('token') as BaseCCMethod) + .afterCrossChainCommandExecute as jest.Mock).mockRejectedValue('error'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.INVALID_CCM_AFTER_CCC_EXECUTION_EXCEPTION, + result: CCMProcessedResult.DISCARDED, + }, + ); + }); + + it('should restore the original state/event when command afterCrossChainCommandExecute fails', async () => { + ((command['interoperableCCMethods'].get('token') as BaseCCMethod) + .afterCrossChainCommandExecute as jest.Mock).mockRejectedValue('error'); + jest.spyOn(context.eventQueue, 'createSnapshot').mockReturnValue(99); + jest.spyOn(context.stateStore, 'createSnapshot').mockReturnValue(10); + jest.spyOn(context.eventQueue, 'restoreSnapshot'); + jest.spyOn(context.stateStore, 'restoreSnapshot'); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(context.eventQueue.restoreSnapshot).toHaveBeenCalledWith(99); + expect(context.stateStore.restoreSnapshot).toHaveBeenCalledWith(10); + }); + + it('should log success event when all the hooks pass', async () => { + const ccMethod = command['interoperableCCMethods'].get('token'); + const ccCommand = command['ccCommands'] + .get(defaultCCM.module) + ?.find(com => com.name === defaultCCM.crossChainCommand); + + await expect(command['_applyRecovery'](context)).resolves.toBeUndefined(); + + expect(ccMethod?.verifyCrossChainMessage).toHaveBeenCalledTimes(1); + expect(ccMethod?.beforeCrossChainCommandExecute).toHaveBeenCalledTimes(1); + expect(ccMethod?.afterCrossChainCommandExecute).toHaveBeenCalledTimes(1); + expect(ccCommand?.verify).toHaveBeenCalledTimes(1); + expect(ccCommand?.execute).toHaveBeenCalledTimes(1); + + expect(context.eventQueue.getEvents()).toHaveLength(1); + expect(command['events'].get(CcmProcessedEvent).log).toHaveBeenCalledWith( + expect.anything(), + context.ccm.receivingChainID, + context.ccm.sendingChainID, + { + ccmID: expect.any(Buffer), + code: CCMProcessedCode.SUCCESS, + result: CCMProcessedResult.APPLIED, + }, + ); + }); + }); });