diff --git a/framework/src/modules/interoperability/base_interoperability_store.ts b/framework/src/modules/interoperability/base_interoperability_store.ts index c5ca92a2641..d826eebf73b 100644 --- a/framework/src/modules/interoperability/base_interoperability_store.ts +++ b/framework/src/modules/interoperability/base_interoperability_store.ts @@ -243,6 +243,17 @@ export abstract class BaseInteroperabilityStore { return true; } + public async terminatedOutboxAccountExist(chainID: Buffer) { + const terminatedOutboxSubstore = this.getStore( + MODULE_ID_INTEROPERABILITY, + STORE_PREFIX_TERMINATED_OUTBOX, + ); + + const doesOutboxExist = await terminatedOutboxSubstore.has(chainID); + + return doesOutboxExist; + } + public async getTerminatedOutboxAccount(chainID: Buffer) { const terminatedOutboxSubstore = this.getStore( MODULE_ID_INTEROPERABILITY, diff --git a/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts b/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts index d39223ac5e3..e7337417deb 100644 --- a/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts +++ b/framework/src/modules/interoperability/mainchain/commands/message_recovery.ts @@ -81,6 +81,14 @@ export class MessageRecoveryCommand extends BaseInteroperabilityCommand { const interoperabilityStore = this.getInteroperabilityStore(getStore); + const doesTerminatedOutboxAccountExist = await interoperabilityStore.terminatedOutboxAccountExist( + chainIdAsBuffer, + ); + + if (!doesTerminatedOutboxAccountExist) { + throw new Error('Terminated outbox account does not exist.'); + } + const terminatedChainOutboxAccount = await interoperabilityStore.getTerminatedOutboxAccount( chainIdAsBuffer, ); 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 d864a9c25d2..d24b4613b98 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 @@ -15,24 +15,47 @@ import { Transaction } from '@liskhq/lisk-chain'; import { codec } from '@liskhq/lisk-codec'; import { getRandomBytes } from '@liskhq/lisk-cryptography'; +import { regularMerkleTree } from '@liskhq/lisk-tree'; +import { when } from 'jest-when'; import { CommandExecuteContext } from '../../../../../../src'; import { BaseCCCommand } from '../../../../../../src/modules/interoperability/base_cc_command'; import { BaseInteroperableAPI } from '../../../../../../src/modules/interoperability/base_interoperable_api'; import { + CHAIN_ACTIVE, COMMAND_ID_MESSAGE_RECOVERY, MODULE_ID_INTEROPERABILITY, } from '../../../../../../src/modules/interoperability/constants'; import { MessageRecoveryCommand } from '../../../../../../src/modules/interoperability/mainchain/commands/message_recovery'; +import { MainchainInteroperabilityStore } from '../../../../../../src/modules/interoperability/mainchain/store'; import { MessageRecoveryParams } from '../../../../../../src/modules/interoperability/mainchain/types'; import { ccmSchema, messageRecoveryParams, } from '../../../../../../src/modules/interoperability/schema'; import { CCMsg } from '../../../../../../src/modules/interoperability/types'; +import { getIDAsKeyForStore } from '../../../../../../src/modules/interoperability/utils'; import { TransactionContext } from '../../../../../../src/node/state_machine'; import { createTransactionContext } from '../../../../../../src/testing'; +type Mocked, Methods extends keyof Type> = Pick< + { [Key in keyof Type]: jest.Mock> }, + Methods +>; + describe('Mainchain MessageRecoveryCommand', () => { + type StoreMock = Mocked< + MainchainInteroperabilityStore, + | 'isLive' + | 'addToOutbox' + | 'getChainAccount' + | 'setTerminatedOutboxAccount' + | 'getTerminatedOutboxAccount' + | 'chainAccountExist' + | 'terminatedOutboxAccountExist' + >; + + const networkID = getRandomBytes(32); + let messageRecoveryCommand: MessageRecoveryCommand; let commandExecuteContext: CommandExecuteContext; let interoperableCCAPIs: Map; @@ -41,6 +64,8 @@ describe('Mainchain MessageRecoveryCommand', () => { let transactionParams: MessageRecoveryParams; let encodedTransactionParams: Buffer; let transactionContext: TransactionContext; + let storeMock: StoreMock; + let ccms: CCMsg[]; beforeEach(() => { interoperableCCAPIs = new Map(); @@ -52,22 +77,24 @@ describe('Mainchain MessageRecoveryCommand', () => { ccCommands, ); - const ccm1: CCMsg = { - nonce: BigInt(0), - moduleID: 1, - crossChainCommandID: 1, - sendingChainID: 2, - receivingChainID: 3, - fee: BigInt(1), - status: 1, - params: Buffer.alloc(0), - }; + ccms = [ + { + nonce: BigInt(0), + moduleID: 1, + crossChainCommandID: 1, + sendingChainID: 2, + receivingChainID: 3, + fee: BigInt(1), + status: 1, + params: Buffer.alloc(0), + }, + ]; - const ccm1Encoded = codec.encode(ccmSchema, ccm1); + const ccmsEncoded = ccms.map(ccm => codec.encode(ccmSchema, ccm)); transactionParams = { chainID: 3, - crossChainMessages: [ccm1Encoded], + crossChainMessages: [...ccmsEncoded], idxs: [0], siblingHashes: [getRandomBytes(32)], }; @@ -91,6 +118,77 @@ describe('Mainchain MessageRecoveryCommand', () => { commandExecuteContext = transactionContext.createCommandExecuteContext( messageRecoveryParams, ); + + storeMock = { + addToOutbox: jest.fn(), + getChainAccount: jest.fn(), + getTerminatedOutboxAccount: jest.fn(), + setTerminatedOutboxAccount: jest.fn(), + chainAccountExist: jest.fn().mockResolvedValue(true), + isLive: jest.fn().mockResolvedValue(true), + terminatedOutboxAccountExist: jest.fn().mockResolvedValue(true), + }; + + jest + .spyOn(messageRecoveryCommand, 'getInteroperabilityStore' as any) + .mockImplementation(() => storeMock); + jest.spyOn(regularMerkleTree, 'calculateRootFromUpdateData').mockReturnValue(Buffer.alloc(32)); + + let chainID; + for (const ccm of ccms) { + chainID = getIDAsKeyForStore(ccm.sendingChainID); + + when(storeMock.getChainAccount) + .calledWith(chainID) + .mockResolvedValue({ + name: `chain${chainID.toString('hex')}`, + status: CHAIN_ACTIVE, + networkID, + lastCertificate: { + height: 1, + timestamp: 10, + stateRoot: Buffer.alloc(0), + validatorsHash: Buffer.alloc(0), + }, + }); + } + + chainID = getIDAsKeyForStore(transactionParams.chainID); + + when(storeMock.getTerminatedOutboxAccount) + .calledWith(chainID) + .mockResolvedValue({ + outboxRoot: getRandomBytes(32), + outboxSize: 1, + partnerChainInboxSize: 1, + }); + }); + + it('should successfully process recovery transaction', async () => { + // Global Setup + await messageRecoveryCommand.execute(commandExecuteContext); + expect.assertions(ccms.length + 1); + + { + // Assign & Arrange + const chainID = getIDAsKeyForStore(transactionParams.chainID); + const outboxRoot = Buffer.alloc(32); + + // Assert + expect(storeMock.setTerminatedOutboxAccount).toHaveBeenCalledWith( + chainID, + expect.objectContaining({ + outboxRoot, + }), + ); + } + + for (const ccm of ccms) { + // Assign + const chainID = getIDAsKeyForStore(ccm.sendingChainID); + // Assert + expect(storeMock.addToOutbox).toHaveBeenCalledWith(chainID, ccm); + } }); it('should throw when beforeRecoverCCM of ccAPIs of the ccm fails', async () => { @@ -112,6 +210,7 @@ describe('Mainchain MessageRecoveryCommand', () => { }); it('should throw when there are no CCMs in the transaction params', async () => { + // Assign & Arrange transactionParams.crossChainMessages = []; encodedTransactionParams = codec.encode(messageRecoveryParams, transactionParams); (transaction as any).params = encodedTransactionParams; @@ -119,8 +218,76 @@ describe('Mainchain MessageRecoveryCommand', () => { messageRecoveryParams, ); + // Assert await expect(messageRecoveryCommand.execute(commandExecuteContext)).rejects.toThrow( 'Transaction parameter has no CCMs', ); }); + + it('should throw when terminated chain outbox does not exist', async () => { + // Assign & Arrange + const chainID = getIDAsKeyForStore(transactionParams.chainID); + + when(storeMock.terminatedOutboxAccountExist).calledWith(chainID).mockResolvedValue(false); + + // Assert + await expect(messageRecoveryCommand.execute(commandExecuteContext)).rejects.toThrow( + 'Terminated outbox account does not exist', + ); + }); + + it('should not add CCM to outbox when sending chain of the CCM does not exist', async () => { + // Assign & Arrange & Act + for (const ccm of ccms) { + const chainID = getIDAsKeyForStore(ccm.sendingChainID); + + when(storeMock.chainAccountExist).calledWith(chainID).mockResolvedValue(false); + } + + await messageRecoveryCommand.execute(commandExecuteContext); + + // Assert + expect.assertions(ccms.length); + for (const _ of ccms) { + expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + } + }); + + it('should not add CCM to outbox when sending chain of the CCM is not live', async () => { + // Assign & Arrange & Act + for (const ccm of ccms) { + const chainID = getIDAsKeyForStore(ccm.sendingChainID); + + when(storeMock.isLive).calledWith(chainID).mockResolvedValue(false); + } + + await messageRecoveryCommand.execute(commandExecuteContext); + + // Assert + expect.assertions(ccms.length); + for (const _ of ccms) { + expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + } + }); + + it('should not add CCM to outbox when sending chain of the CCM is not active', async () => { + // Assign & Arrange & Act + for (const ccm of ccms) { + const chainID = getIDAsKeyForStore(ccm.sendingChainID); + + when(storeMock.getChainAccount) + .calledWith(chainID) + .mockResolvedValue({ + status: -1, + } as any); + } + + await messageRecoveryCommand.execute(commandExecuteContext); + + // Assert + expect.assertions(ccms.length); + for (const _ of ccms) { + expect(storeMock.addToOutbox).not.toHaveBeenCalled(); + } + }); });