diff --git a/examples/interop/pos-mainchain-fast/config/custom_config_node_one.json b/examples/interop/pos-mainchain-fast/config/custom_config_node_one.json index 736e377a30..ab446d8d28 100644 --- a/examples/interop/pos-mainchain-fast/config/custom_config_node_one.json +++ b/examples/interop/pos-mainchain-fast/config/custom_config_node_one.json @@ -1,7 +1,12 @@ { "system": { - "dataPath": "~/.lisk/pos-mainchain-node-one", - "logLevel": "info" + "dataPath": "~/.lisk/mainchain-node-one", + "logLevel": "debug", + "keepEventsForHeights": -1, + "keepInclusionProofsForHeights": -1, + "inclusionProofKeys": [ + "83ed0d250000160811fdaf692ba77eabfbfc3a6bb3c4cf6a87beafd28cfe90b5dc64cb20ab46" + ] }, "rpc": { "modes": ["ipc"], diff --git a/examples/interop/pos-mainchain-fast/config/custom_config_node_two.json b/examples/interop/pos-mainchain-fast/config/custom_config_node_two.json index 99c18dcc72..d4e0812e9d 100644 --- a/examples/interop/pos-mainchain-fast/config/custom_config_node_two.json +++ b/examples/interop/pos-mainchain-fast/config/custom_config_node_two.json @@ -1,7 +1,12 @@ { "system": { - "dataPath": "~/.lisk/pos-mainchain-node-two", - "logLevel": "info" + "dataPath": "~/.lisk/mainchain-node-two", + "logLevel": "debug", + "keepEventsForHeights": -1, + "keepInclusionProofsForHeights": -1, + "inclusionProofKeys": [ + "83ed0d250000ac894ab085f50b81fe000e99ccb27e5543de69f63d4f9105daab15dce90f81b3" + ] }, "rpc": { "modes": ["ipc"], diff --git a/examples/interop/pos-mainchain-fast/config/default/config.json b/examples/interop/pos-mainchain-fast/config/default/config.json index 38ecf0f438..4f2b2b8709 100644 --- a/examples/interop/pos-mainchain-fast/config/default/config.json +++ b/examples/interop/pos-mainchain-fast/config/default/config.json @@ -1,13 +1,4 @@ { - "system": { - "dataPath": "~/.lisk/pos-mainchain-fast", - "keepEventsForHeights": -1, - "keepInclusionProofsForHeights": -1, - "inclusionProofKeys": [ - "83ed0d250000160811fdaf692ba77eabfbfc3a6bb3c4cf6a87beafd28cfe90b5dc64cb20ab46" - ], - "logLevel": "info" - }, "rpc": { "modes": ["ipc"], "port": 7881, diff --git a/examples/interop/pos-sidechain-example-one/config/default/config.json b/examples/interop/pos-sidechain-example-one/config/default/config.json index 55f8adb31d..48b73923fa 100644 --- a/examples/interop/pos-sidechain-example-one/config/default/config.json +++ b/examples/interop/pos-sidechain-example-one/config/default/config.json @@ -2,7 +2,11 @@ "system": { "dataPath": "~/.lisk/pos-sidechain-example-one", "keepEventsForHeights": 300, - "logLevel": "info" + "logLevel": "info", + "keepInclusionProofsForHeights": -1, + "inclusionProofKeys": [ + "83ed0d250000fb5e512425fc9449316ec95969ebe71e2d576dbab833d61e2a5b9330fd70ee02" + ] }, "rpc": { "modes": ["ipc"], diff --git a/examples/interop/pos-sidechain-example-two/config/default/config.json b/examples/interop/pos-sidechain-example-two/config/default/config.json index 0297e306f9..2a1575e421 100644 --- a/examples/interop/pos-sidechain-example-two/config/default/config.json +++ b/examples/interop/pos-sidechain-example-two/config/default/config.json @@ -2,7 +2,11 @@ "system": { "dataPath": "~/.lisk/pos-sidechain-example-two", "keepEventsForHeights": 300, - "logLevel": "info" + "logLevel": "info", + "keepInclusionProofsForHeights": -1, + "inclusionProofKeys": [ + "83ed0d250000fb5e512425fc9449316ec95969ebe71e2d576dbab833d61e2a5b9330fd70ee02" + ] }, "rpc": { "modes": ["ipc"], diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/active_validators_update.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/active_validators_update.ts index 4bf2e7f5b7..be282eef15 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/active_validators_update.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/active_validators_update.ts @@ -12,53 +12,31 @@ * Removal or modification of this copyright notice is prohibited. */ /* eslint-disable no-bitwise */ -import { - ActiveValidator, - Certificate, - LastCertificate, - utils, - ActiveValidatorsUpdate, -} from 'lisk-sdk'; -import { ValidatorsData } from './types'; +import { ActiveValidator, utils, ActiveValidatorsUpdate } from 'lisk-sdk'; +import { ValidatorsDataWithHeight } from './types'; /** * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0053.md#computing-the-validators-update */ + export const calculateActiveValidatorsUpdate = ( - certificate: Certificate, - validatorsHashPreimage: ValidatorsData[], - lastCertificate: LastCertificate, + validatorsDataAtLastCertificate: ValidatorsDataWithHeight, + validatorsDataAtNewCertificate: ValidatorsDataWithHeight, ): { activeValidatorsUpdate: ActiveValidatorsUpdate; certificateThreshold: bigint } => { let certificateThreshold; - const validatorDataAtCertificate = validatorsHashPreimage.find(validatorsData => - validatorsData.validatorsHash.equals(certificate.validatorsHash), - ); - - if (!validatorDataAtCertificate) { - throw new Error('No validators data found for the certificate height.'); - } - - const validatorDataAtLastCertificate = validatorsHashPreimage.find(validatorsData => - validatorsData.validatorsHash.equals(lastCertificate.validatorsHash), - ); - - if (!validatorDataAtLastCertificate) { - throw new Error('No validators data found for the given last certificate height.'); - } - // If the certificate threshold is not changed from last certificate then we assign zero if ( - validatorDataAtCertificate.certificateThreshold === - validatorDataAtLastCertificate.certificateThreshold + validatorsDataAtNewCertificate.certificateThreshold === + validatorsDataAtLastCertificate.certificateThreshold ) { - certificateThreshold = validatorDataAtLastCertificate.certificateThreshold; + certificateThreshold = validatorsDataAtLastCertificate.certificateThreshold; } else { - certificateThreshold = validatorDataAtCertificate.certificateThreshold; + certificateThreshold = validatorsDataAtNewCertificate.certificateThreshold; } const activeValidatorsUpdate = getActiveValidatorsUpdate( - validatorDataAtLastCertificate.validators, - validatorDataAtCertificate.validators, + validatorsDataAtLastCertificate.validators, + validatorsDataAtNewCertificate.validators, ); return { activeValidatorsUpdate, certificateThreshold }; diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/block_event_handler.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/block_event_handler.ts new file mode 100644 index 0000000000..33a3dd3a0d --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/block_event_handler.ts @@ -0,0 +1,483 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + chain, + CCMProcessedResult, + CcmProcessedEventData, + CcmSendSuccessEventData, + LastCertificate, + MODULE_NAME_INTEROPERABILITY, + codec, + ChainAccount, + getMainchainID, +} from 'lisk-sdk'; +import { ChainAPIClient } from './chain_api_client'; +import { ChainConnectorDB } from './db'; +import { BlockHeader, LastSentCCM, Logger, ModuleMetadata } from './types'; +import { CCM_PROCESSED, CCM_SEND_SUCCESS, DEFAULT_SENT_CCU_TIMEOUT } from './constants'; +import { CCUHandler } from './ccu_handler'; + +interface NewBlockHandlerConfig { + registrationHeight: number; + ownChainID: Buffer; + receivingChainID: Buffer; + maxCCUSize: number; + ccuFee: string; + isSaveCCU: boolean; + ccuSaveLimit: number; +} + +interface NewBlockHandlerInitArgs { + logger: Logger; + db: ChainConnectorDB; + sendingChainAPIClient: ChainAPIClient; + receivingChainAPIClient: ChainAPIClient; +} + +interface Data { + readonly blockHeader: chain.BlockHeaderJSON; +} +type FinalizedHeightInfo = { inboxSize: number; lastCertificateHeight: number }; + +export class BlockEventHandler { + private readonly _ownChainID!: Buffer; + private readonly _ccuHandler!: CCUHandler; + private readonly _ccuFrequency!: number; + private readonly _ccuSaveLimit: number; + private readonly _receivingChainID: Buffer; + private readonly _isReceivingChainMainchain!: boolean; + private _db!: ChainConnectorDB; + private _logger!: Logger; + private _sendingChainAPIClient!: ChainAPIClient; + private _receivingChainAPIClient!: ChainAPIClient; + private _lastCertificate!: LastCertificate; + private _interoperabilityMetadata!: ModuleMetadata; + private _heightToDeleteIndex!: Map; + private _receivingChainFinalizedHeight!: number; + private _isReceivingChainRegistered = false; + private _lastSentCCUTxID = ''; + private _lastSentCCM!: LastSentCCM; + private _lastIncludedCCMOnReceivingChain!: LastSentCCM | undefined; + private _lastDeletionHeight!: number; + private _sentCCUTxTimeout!: NodeJS.Timer; + + public constructor(config: NewBlockHandlerConfig) { + this._ownChainID = config.ownChainID; + this._ccuSaveLimit = config.ccuSaveLimit; + this._receivingChainFinalizedHeight = 0; + this._receivingChainID = config.receivingChainID; + // If the running node is mainchain then receiving chain will be sidechain or vice verse. + this._isReceivingChainMainchain = !getMainchainID(this._ownChainID).equals(this._ownChainID); + this._ccuHandler = new CCUHandler({ + maxCCUSize: config.maxCCUSize, + ownChainID: config.ownChainID, + receivingChainID: config.receivingChainID, + registrationHeight: config.registrationHeight, + ccuFee: config.ccuFee, + isSaveCCU: config.isSaveCCU, + }); + } + + public async load(args: NewBlockHandlerInitArgs) { + this._logger = args.logger; + this._db = args.db; + this._sendingChainAPIClient = args.sendingChainAPIClient; + this._receivingChainAPIClient = args.receivingChainAPIClient; + this._heightToDeleteIndex = new Map(); + this._interoperabilityMetadata = await this._sendingChainAPIClient.getMetadataByModuleName( + MODULE_NAME_INTEROPERABILITY, + ); + this._ccuHandler.load({ + db: args.db, + lastCertificate: this._lastCertificate, + logger: args.logger, + receivingChainAPIClient: args.receivingChainAPIClient, + sendingChainAPIClient: args.sendingChainAPIClient, + interoperabilityMetadata: this._interoperabilityMetadata, + }); + + await this._receivingChainAPIClient.connect(); + this._lastIncludedCCMOnReceivingChain = await this._db.getLastSentCCM(); + // On a new block start with CCU creation process + this._sendingChainAPIClient.subscribe( + 'chain_newBlock', + async (data?: Record) => { + try { + await this._handleNewBlock(data); + } catch (error) { + this._logger.error({ err: error as Error }, 'Failed to handle new block.'); + } + }, + ); + + this._sendingChainAPIClient.subscribe( + 'chain_deleteBlock', + async (data?: Record) => { + try { + await this._deleteBlockHandler(data); + } catch (error) { + this._logger.error({ err: error as Error }, 'Failed to handle delete block.'); + } + }, + ); + + // Initialize the receiving chain client in the end of load so not to miss the initial new blocks + this._initializeReceivingChainClient().catch(this._logger.error); + } + + private async _handleNewBlock(data?: Record) { + const { blockHeader: receivedBlock } = data as unknown as Data; + const newBlockHeader = chain.BlockHeader.fromJSON(receivedBlock).toObject(); + + await this._saveOnNewBlock(newBlockHeader); + + const nodeInfo = await this._sendingChainAPIClient.getNodeInfo(); + + if (nodeInfo.syncing) { + this._logger.debug('No CCU generation is possible as the node is syncing.'); + return; + } + let chainAccount: ChainAccount | undefined; + + // Fetch last certificate from the receiving chain and update the _lastCertificate + try { + chainAccount = await this._receivingChainAPIClient.getChainAccount(this._ownChainID); + } catch (error) { + // If receivingChainAPIClient is not ready then still save data on new block + // Error is handled within this function so need to have try/catch + await this._initializeReceivingChainClient(); + this._logger.error( + { err: error as Error }, + 'Error occurred while accessing receivingChainAPIClient but all data is saved on new block.', + ); + + return; + } + + // If sending chain is not registered with the receiving chain then only save data on new block and exit + if (!chainAccount || (chainAccount && !chainAccount.lastCertificate)) { + this._logger.info( + 'Sending chain is not registered to the receiving chain yet and has no chain data.', + ); + return; + } + + this._lastCertificate = chainAccount.lastCertificate; + this._logger.info( + `Last certificate value has been set with height ${this._lastCertificate.height}`, + ); + + const numOfBlocksSinceLastCertificate = newBlockHeader.height - this._lastCertificate.height; + if (this._ccuFrequency > numOfBlocksSinceLastCertificate) { + this._logger.info( + { + ccuFrequency: this._ccuFrequency, + nextPossibleCCUHeight: this._ccuFrequency - numOfBlocksSinceLastCertificate, + }, + 'No attempt to create CCU either due to provided ccuFrequency', + ); + + return; + } + + // Check if receiving chain is registered yet or not + if (!this._isReceivingChainRegistered) { + const receivingChainAccount = await this._sendingChainAPIClient.getChainAccount( + this._receivingChainID, + ); + if (!receivingChainAccount) { + this._logger.info( + 'Receiving chain is not registered on the sending chain yet and has no chain data.', + ); + return; + } + this._isReceivingChainRegistered = true; + } + + let computedCCUParams; + + // Compute CCU when there is no pending CCU that was sent earlier + if (this._lastSentCCUTxID === '') { + computedCCUParams = await this._ccuHandler.computeCCU( + this._lastCertificate, + this._lastIncludedCCMOnReceivingChain, + ); + } else { + this._logger.info( + `Still pending CCU on the receiving CCU with tx ID ${this._lastSentCCUTxID}`, + ); + + return; + } + + if (!computedCCUParams) { + this._logger.info(`No CCU params were generated for block height ${newBlockHeader.height}`); + + return; + } + + const ccuSubmitResult = await this._ccuHandler.submitCCU( + computedCCUParams.ccuParams, + this._lastSentCCUTxID, + ); + if (ccuSubmitResult) { + this._lastSentCCUTxID = ccuSubmitResult; + // Wait until 1 hour + this._sentCCUTxTimeout = setTimeout(() => { + this._lastSentCCUTxID = ''; + clearTimeout(this._sentCCUTxTimeout); + }, DEFAULT_SENT_CCU_TIMEOUT); + // If CCU was sent successfully then save the lastSentCCM if any + if (computedCCUParams.lastCCMToBeSent) { + this._lastSentCCM = computedCCUParams.lastCCMToBeSent; + this._logger.info( + `Last sent CCM with nonce ${this._lastSentCCM.nonce.toString()} is saved.`, + ); + } + this._logger.info(`CCU transaction was successfully sent with Tx ID ${ccuSubmitResult}.`); + return; + } + this._logger.info( + `Last sent CCU tx with ID ${this._lastSentCCUTxID} was not yet included in the receiving chain.`, + ); + } + + private async _saveOnNewBlock(newBlockHeader: BlockHeader) { + await this._db.saveToDBOnNewBlock(newBlockHeader); + // Check for events if any and store them + const events = await this._sendingChainAPIClient.getEvents(newBlockHeader.height); + + const ccmsFromEvents = []; + // eslint-disable-next-line no-restricted-syntax, no-labels + ccmInEventsCheck: if (events && events.length > 0) { + const ccmSendSuccessEvents = events.filter( + eventAttr => + eventAttr.name === CCM_SEND_SUCCESS && eventAttr.module === MODULE_NAME_INTEROPERABILITY, + ); + + const ccmProcessedEvents = events.filter( + eventAttr => + eventAttr.name === CCM_PROCESSED && eventAttr.module === MODULE_NAME_INTEROPERABILITY, + ); + + if (ccmSendSuccessEvents.length === 0 && ccmProcessedEvents.length === 0) { + // If there are no CCMs present in the events for the height then skip CCM saving part + // eslint-disable-next-line no-labels + break ccmInEventsCheck; + } + + // Save ccm send success events + if (ccmSendSuccessEvents.length > 0) { + const ccmSendSuccessEventInfo = this._interoperabilityMetadata.events.filter( + e => e.name === CCM_SEND_SUCCESS, + ); + + if (!ccmSendSuccessEventInfo?.[0]?.data) { + throw new Error('No schema found for "ccmSendSuccess" event data.'); + } + + for (const e of ccmSendSuccessEvents) { + const eventData = codec.decode( + ccmSendSuccessEventInfo[0].data, + Buffer.from(e.data, 'hex'), + ); + ccmsFromEvents.push(eventData.ccm); + } + } + // Save ccm processed events based on CCMProcessedResult FORWARDED = 1 + if (ccmProcessedEvents.length > 0) { + const ccmProcessedEventInfo = this._interoperabilityMetadata.events.filter( + e => e.name === CCM_PROCESSED, + ); + + if (!ccmProcessedEventInfo?.[0]?.data) { + throw new Error('No schema found for "ccmProcessed" event data.'); + } + + for (const e of ccmProcessedEvents) { + const eventData = codec.decode( + ccmProcessedEventInfo[0].data, + Buffer.from(e.data, 'hex'), + ); + if (eventData.result === CCMProcessedResult.FORWARDED) { + ccmsFromEvents.push(eventData.ccm); + } + } + } + } + + await this._db.setCCMsByHeight( + ccmsFromEvents + .map(ccm => ({ ...ccm, height: newBlockHeader.height })) + .filter( + ccm => + this._isReceivingChainMainchain || ccm.receivingChainID.equals(this._receivingChainID), + ), + newBlockHeader.height, + ); + + const validatorsData = await this._sendingChainAPIClient.getBFTParametersAtHeight( + newBlockHeader.height, + ); + + await this._db.setValidatorsDataByHash( + validatorsData.validatorsHash, + { ...validatorsData, height: newBlockHeader.height }, + newBlockHeader.height, + ); + } + + private async _initializeReceivingChainClient() { + try { + await this._receivingChainAPIClient.connect(); + this._receivingChainAPIClient.subscribe( + 'chain_newBlock', + async (data?: Record) => this._newBlockReceivingChainHandler(data), + ); + } catch (error) { + this._logger.error( + error, + 'Not able to connect to receivingChainAPIClient. Trying again on next new block.', + ); + } + } + + private async _newBlockReceivingChainHandler(_?: Record) { + try { + const { finalizedHeight, syncing } = await this._receivingChainAPIClient.getNodeInfo(); + // If receiving node is syncing then return + if (syncing) { + this._logger.debug('Receiving chain is syncing.'); + return; + } + this._receivingChainFinalizedHeight = finalizedHeight; + const channelDataOnReceivingChain = await this._receivingChainAPIClient.getChannelAccount( + this._ownChainID, + ); + if (!channelDataOnReceivingChain) { + throw new Error('No channel data available on receiving chain.'); + } + const chainAccount = await this._receivingChainAPIClient.getChainAccount(this._ownChainID); + if (!chainAccount) { + throw new Error('No chain data available on receiving chain.'); + } + this._heightToDeleteIndex.set(finalizedHeight, { + inboxSize: channelDataOnReceivingChain.inbox.size, + lastCertificateHeight: chainAccount.lastCertificate?.height, + }); + if (this._lastSentCCUTxID !== '') { + try { + await this._receivingChainAPIClient.getTransactionByID(this._lastSentCCUTxID); + this._logger.info( + `CCU transaction with ${this._lastSentCCUTxID} was included on the receiving chain`, + ); + // Reset last sent CCU to be blank + this._lastSentCCUTxID = ''; + clearTimeout(this._sentCCUTxTimeout); + // Update last included CCM if there was any in the last sent CCU + if (this._lastSentCCM) { + this._lastIncludedCCMOnReceivingChain = this._lastSentCCM; + await this._db.setLastSentCCM(this._lastIncludedCCMOnReceivingChain); + } + } catch (error) { + throw new Error(`Failed to get transaction with ID ${this._lastSentCCUTxID}`); + } + } + await this._cleanup(); + } catch (error) { + this._logger.debug(error, 'Error occured while receiving block from receiving chain.'); + } + } + + private async _cleanup() { + // Delete CCUs + // When given -1 then there is no limit + if (this._ccuSaveLimit !== -1) { + const { list: listOfCCUs, total } = await this._db.getListOfCCUs(); + if (total > this._ccuSaveLimit) { + // listOfCCUs is a descending list of CCUs by nonce + for (let i = total - 1; i >= this._ccuSaveLimit; i -= 1) { + await this._db.deleteCCUTransaction(Buffer.from(listOfCCUs[i]?.id as string, 'hex')); + } + } + } + let finalizedInfoAtHeight = this._heightToDeleteIndex.get(this._receivingChainFinalizedHeight); + if (!finalizedInfoAtHeight) { + for (let i = 1; i < this._heightToDeleteIndex.size; i += 1) { + if (this._heightToDeleteIndex.get(this._receivingChainFinalizedHeight - i)) { + finalizedInfoAtHeight = this._heightToDeleteIndex.get( + this._receivingChainFinalizedHeight - i, + ); + break; + } + } + } + + const endDeletionHeightByLastCertificate = finalizedInfoAtHeight + ? finalizedInfoAtHeight.lastCertificateHeight + : 0; + + if (this._lastCertificate.height > 0) { + // Delete CCMs + await this._db.deleteCCMsBetweenHeight( + this._lastDeletionHeight, + endDeletionHeightByLastCertificate - 1, + ); + + // Delete blockHeaders + await this._db.deleteBlockHeadersBetweenHeight( + this._lastDeletionHeight, + endDeletionHeightByLastCertificate - 1, + ); + + // Delete aggregateCommits + await this._db.deleteAggregateCommitsBetweenHeight( + this._lastDeletionHeight, + endDeletionHeightByLastCertificate - 1, + ); + + // Delete validatorsHashPreimage + await this._db.deleteValidatorsHashBetweenHeights( + this._lastDeletionHeight, + endDeletionHeightByLastCertificate - 1, + ); + + this._lastDeletionHeight = endDeletionHeightByLastCertificate; + + this._logger.debug( + `Deleted data on cleanup between heights 1 and ${endDeletionHeightByLastCertificate}`, + ); + } + + // Delete info less than finalized height + this._heightToDeleteIndex.forEach((_, key) => { + if (key < this._receivingChainFinalizedHeight) { + this._heightToDeleteIndex.delete(key); + } + }); + } + + private async _deleteBlockHandler(data?: Record) { + const { blockHeader: receivedBlock } = data as unknown as Data; + + const deletedBlockHeader = chain.BlockHeader.fromJSON(receivedBlock).toObject(); + + // Delete ccmEvents for the height of blockHeader + await this._db.deleteCCMsByHeight(deletedBlockHeader.height); + await this._db.deleteBlockHeaderByHeight(deletedBlockHeader.height); + await this._db.deleteAggregateCommitByHeight(deletedBlockHeader.height); + await this._db.deleteValidatorsHashByHeight(deletedBlockHeader.height); + } +} diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/ccu_handler.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/ccu_handler.ts new file mode 100644 index 0000000000..d1849aae3d --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/ccu_handler.ts @@ -0,0 +1,379 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + ActiveValidatorsUpdate, + CrossChainUpdateTransactionParams, + EMPTY_BYTES, + LastCertificate, + MODULE_NAME_INTEROPERABILITY, + Transaction, + ccuParamsSchema, + certificateSchema, + codec, + cryptography, + getMainchainID, + transactions, +} from 'lisk-sdk'; +import { ChainConnectorDB } from './db'; +import { ChainAPIClient } from './chain_api_client'; +import { + getCertificateFromAggregateCommitByBlockHeader, + getNextCertificateFromAggregateCommits, +} from './certificate_generation'; +import { + COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, + COMMAND_NAME_SUBMIT_SIDECHAIN_CCU, + DEFAULT_LAST_CCM_SENT_NONCE, +} from './constants'; +import { calculateMessageWitnesses } from './inbox_update'; +import { LastSentCCM, Logger, ModuleMetadata } from './types'; +import { calculateActiveValidatorsUpdate } from './active_validators_update'; + +interface ComputeCCUConfig { + registrationHeight: number; + ownChainID: Buffer; + receivingChainID: Buffer; + maxCCUSize: number; + ccuFee: string; + isSaveCCU: boolean; +} + +interface ComputeCCUInitArgs { + logger: Logger; + db: ChainConnectorDB; + sendingChainAPIClient: ChainAPIClient; + receivingChainAPIClient: ChainAPIClient; + lastCertificate: LastCertificate; + interoperabilityMetadata: ModuleMetadata; +} + +export class CCUHandler { + private readonly _registrationHeight: number; + private readonly _ownChainID: Buffer; + private readonly _receivingChainID: Buffer; + private readonly _maxCCUSize: number; + private readonly _isReceivingChainMainchain: boolean; + private readonly _isSaveCCU: boolean; + private readonly _ccuFee: string; + private _db!: ChainConnectorDB; + private _logger!: Logger; + private _sendingChainAPIClient!: ChainAPIClient; + private _receivingChainAPIClient!: ChainAPIClient; + private _lastCertificate!: LastCertificate; + private _interoperabilityMetadata!: ModuleMetadata; + private _outboxKeyForInclusionProof!: Buffer; + + public constructor(config: ComputeCCUConfig) { + this._registrationHeight = config.registrationHeight; + this._ownChainID = config.ownChainID; + this._receivingChainID = config.receivingChainID; + this._maxCCUSize = config.maxCCUSize; + this._ccuFee = config.ccuFee; + this._isSaveCCU = config.isSaveCCU; + // If the running node is mainchain then receiving chain will be sidechain or vice verse. + this._isReceivingChainMainchain = !getMainchainID(this._ownChainID).equals(this._ownChainID); + } + + public load(args: ComputeCCUInitArgs) { + this._logger = args.logger; + this._db = args.db; + this._sendingChainAPIClient = args.sendingChainAPIClient; + this._receivingChainAPIClient = args.receivingChainAPIClient; + this._lastCertificate = args.lastCertificate; + this._interoperabilityMetadata = args.interoperabilityMetadata; + + const store = this._interoperabilityMetadata.stores.find( + s => s.data.$id === '/modules/interoperability/outbox', + ); + // Calculate the inclusion proof of the outbox root on state root + this._outboxKeyForInclusionProof = Buffer.concat([ + Buffer.from(store?.key as string, 'hex'), + cryptography.utils.hash(this._receivingChainID), + ]); + } + + public async computeCCU( + lastCertificate: LastCertificate, + lastIncludedCCM?: LastSentCCM, + ): Promise< + | { + ccuParams: CrossChainUpdateTransactionParams; + lastCCMToBeSent: LastSentCCM | undefined; + } + | undefined + > { + this._lastCertificate = lastCertificate; + const newCertificate = await this._findCertificate(); + if (!newCertificate && this._lastCertificate.height === 0) { + return undefined; + } + + const lastSentCCM = lastIncludedCCM ?? { + nonce: DEFAULT_LAST_CCM_SENT_NONCE, + height: this._lastCertificate.height, + }; + + // Get range of CCMs and update the DB accordingly + const ccmsRange = await this._db.getCCMsBetweenHeights( + lastSentCCM.height, + newCertificate ? newCertificate.height : this._lastCertificate.height, + ); + const channelDataOnReceivingChain = await this._receivingChainAPIClient.getChannelAccount( + this._ownChainID, + ); + if (!channelDataOnReceivingChain) { + return undefined; + } + const channelDataOnSendingChain = await this._sendingChainAPIClient.getChannelAccount( + this._receivingChainID, + ); + if (!channelDataOnSendingChain) { + return undefined; + } + const { crossChainMessages, lastCCMToBeSent, messageWitnessHashes } = calculateMessageWitnesses( + channelDataOnReceivingChain.inbox.size, + channelDataOnSendingChain?.outbox.size, + lastSentCCM, + ccmsRange, + this._maxCCUSize, + ); + let activeValidatorsUpdate: ActiveValidatorsUpdate = { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: EMPTY_BYTES, + }; + let certificate = EMPTY_BYTES; + let certificateThreshold; + let outboxRootWitness; + + if (!newCertificate) { + if (crossChainMessages.length === 0) { + this._logger.info( + 'CCU cant be created as there are no pending CCMs for the last certificate.', + ); + return undefined; + } + // Empty outboxRootWitness for last certificate + outboxRootWitness = { + bitmap: EMPTY_BYTES, + siblingHashes: [], + }; + + // Use the old certificateThreshold + const validatorsDataAtLastCertificate = await this._db.getValidatorsDataByHash( + this._lastCertificate.validatorsHash, + ); + certificateThreshold = validatorsDataAtLastCertificate?.certificateThreshold; + + return { + ccuParams: { + sendingChainID: this._ownChainID, + activeValidatorsUpdate, + certificate, + certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness, + }, + } as CrossChainUpdateTransactionParams, + lastCCMToBeSent, + }; + } + + const validatorsDataAtLastCertificate = await this._db.getValidatorsDataByHash( + this._lastCertificate.validatorsHash, + ); + + if (!validatorsDataAtLastCertificate) { + throw new Error( + `No validators data at last certificate with hash at ${this._lastCertificate.validatorsHash.toString( + 'hex', + )}`, + ); + } + if (!this._lastCertificate.validatorsHash.equals(newCertificate.validatorsHash)) { + const validatorsDataAtNewCertificate = await this._db.getValidatorsDataByHash( + newCertificate.validatorsHash, + ); + if (!validatorsDataAtNewCertificate) { + throw new Error( + `No validators data at new certificate with hash at ${newCertificate.validatorsHash.toString( + 'hex', + )}`, + ); + } + const validatorsUpdateResult = calculateActiveValidatorsUpdate( + validatorsDataAtLastCertificate, + validatorsDataAtNewCertificate, + ); + activeValidatorsUpdate = validatorsUpdateResult.activeValidatorsUpdate; + certificateThreshold = validatorsUpdateResult.certificateThreshold; + } else { + // If there was no activeValidatorsUpdate then use the old certificateThreshold + certificateThreshold = validatorsDataAtLastCertificate?.certificateThreshold; + } + + if (crossChainMessages.length === 0) { + outboxRootWitness = { + bitmap: EMPTY_BYTES, + siblingHashes: [], + }; + } else { + const inclusionProofs = await this._sendingChainAPIClient.getSavedInclusionProofAtHeight( + newCertificate.height, + ); + + const foundInclusionProof = inclusionProofs.proof.queries.find(q => + q.key.equals(this._outboxKeyForInclusionProof), + ); + if (!foundInclusionProof) { + throw new Error( + `No inclusion proof was found for key ${this._outboxKeyForInclusionProof.toString( + 'hex', + )}`, + ); + } + outboxRootWitness = { + bitmap: foundInclusionProof.bitmap, + siblingHashes: inclusionProofs.proof.siblingHashes, + }; + } + + certificate = codec.encode(certificateSchema, newCertificate); + + return { + ccuParams: { + sendingChainID: this._ownChainID, + activeValidatorsUpdate, + certificate, + certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness, + }, + } as CrossChainUpdateTransactionParams, + lastCCMToBeSent, + }; + } + + public async submitCCU( + ccuParams: CrossChainUpdateTransactionParams, + lastSentCCUTxID: string, + ): Promise { + if (!this._db.privateKey) { + throw new Error('There is no key enabled to submit CCU.'); + } + const { syncing } = await this._receivingChainAPIClient.getNodeInfo(); + if (syncing) { + throw new Error('Receiving node is syncing.'); + } + const relayerPublicKey = cryptography.ed.getPublicKeyFromPrivateKey(this._db.privateKey); + const targetCommand = this._isReceivingChainMainchain + ? COMMAND_NAME_SUBMIT_MAINCHAIN_CCU + : COMMAND_NAME_SUBMIT_SIDECHAIN_CCU; + + const nonce = await this._receivingChainAPIClient.getAuthAccountNonceFromPublicKey( + relayerPublicKey, + ); + + const txWithoutFee = { + module: MODULE_NAME_INTEROPERABILITY, + command: targetCommand, + nonce: BigInt(nonce), + senderPublicKey: relayerPublicKey, + params: codec.encode(ccuParamsSchema, ccuParams), + signatures: [], + }; + + const tx = new Transaction({ + ...txWithoutFee, + fee: await this._getCcuFee({ + ...txWithoutFee, + params: ccuParams, + }), + }); + + tx.sign(this._receivingChainID, this._db.privateKey); + if (tx.id.equals(Buffer.from(lastSentCCUTxID, 'hex'))) { + return undefined; + } + let result: { transactionId: string }; + if (this._isSaveCCU) { + result = { transactionId: tx.id.toString('hex') }; + } else { + result = await this._receivingChainAPIClient.postTransaction(tx.getBytes()); + } + // Save the sent CCU + await this._db.setCCUTransaction(tx.toObject()); + + return result.transactionId; + } + + private async _findCertificate() { + // First certificate can be picked directly from first valid aggregateCommit taking registration height into account + if (this._lastCertificate.height === 0) { + const aggreggateCommits = await this._db.getAggregateCommitBetweenHeights( + this._registrationHeight, + 1000, + ); + for (const aggregateCommit of aggreggateCommits) { + const blockHeader = await this._db.getBlockHeaderByHeight(aggregateCommit.height); + if (!blockHeader) { + continue; + } + // When we receive the first aggregateCommit in the chain we can create certificate directly + const firstCertificate = getCertificateFromAggregateCommitByBlockHeader( + aggregateCommit, + blockHeader, + ); + + return firstCertificate; + } + + return undefined; + } + + const bftHeights = await this._sendingChainAPIClient.getBFTHeights(); + + return getNextCertificateFromAggregateCommits(this._db, bftHeights, this._lastCertificate); + } + + private async _getCcuFee(tx: Record): Promise { + let additionalFee = BigInt(0); + + const userBalance = await this._receivingChainAPIClient.hasUserTokenAccount( + cryptography.address.getLisk32AddressFromAddress( + cryptography.address.getAddressFromPublicKey(tx.senderPublicKey as Buffer), + ), + ); + + if (!userBalance.exists) { + const fee = await this._receivingChainAPIClient.getTokenInitializationFee(); + additionalFee += BigInt(fee.userAccount); + } + + const ccuFee = BigInt(this._ccuFee ?? '0') + additionalFee; + const computedMinFee = transactions.computeMinFee(tx, ccuParamsSchema, { + additionalFee, + }); + + if (ccuFee > computedMinFee) { + return ccuFee; + } + return computedMinFee; + } +} diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/certificate_generation.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/certificate_generation.ts index da342b1a53..a083e28d0c 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/certificate_generation.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/certificate_generation.ts @@ -20,42 +20,33 @@ import { computeUnsignedCertificateFromBlockHeader, LastCertificate, } from 'lisk-sdk'; -import { BlockHeader, ValidatorsData } from './types'; +import { BlockHeader } from './types'; +import { ChainConnectorDB } from './db'; /** * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0061.md#getcertificatefromaggregatecommit */ -export const getCertificateFromAggregateCommit = ( +export const getCertificateFromAggregateCommitByBlockHeader = ( aggregateCommit: AggregateCommit, - blockHeaders: BlockHeader[], -): Certificate => { - const blockHeader = blockHeaders.find(header => header.height === aggregateCommit.height); - - if (!blockHeader) { - throw new Error( - `No block header found for the given aggregate height ${aggregateCommit.height} when calling getCertificateFromAggregateCommit.`, - ); - } - - return { - ...computeUnsignedCertificateFromBlockHeader(new chain.BlockHeader(blockHeader)), - aggregationBits: aggregateCommit.aggregationBits, - signature: aggregateCommit.certificateSignature, - }; -}; + blockHeader: BlockHeader, +): Certificate => ({ + ...computeUnsignedCertificateFromBlockHeader(new chain.BlockHeader(blockHeader)), + aggregationBits: aggregateCommit.aggregationBits, + signature: aggregateCommit.certificateSignature, +}); /** * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0061.md#execution-8 */ -export const checkChainOfTrust = ( +export const checkChainOfTrust = async ( lastValidatorsHash: Buffer, blsKeyToBFTWeight: Record, lastCertificateThreshold: bigint, aggregateCommit: AggregateCommit, - blockHeaders: BlockHeader[], - validatorsHashPreimage: ValidatorsData[], -): boolean => { - const blockHeader = blockHeaders.find(header => header.height === aggregateCommit.height - 1); + db: ChainConnectorDB, +): Promise => { + const blockHeader = await db.getBlockHeaderByHeight(aggregateCommit.height - 1); + if (!blockHeader) { throw new Error( `No block header found for the given the previous height ${ @@ -70,9 +61,7 @@ export const checkChainOfTrust = ( } let aggregateBFTWeight = BigInt(0); - const validatorData = validatorsHashPreimage.find(data => - data.validatorsHash.equals(blockHeader.validatorsHash), - ); + const validatorData = await db.getValidatorsDataByHash(blockHeader.validatorsHash); if (!validatorData) { throw new Error( `No validators data found for the given validatorsHash ${blockHeader.validatorsHash.toString( @@ -99,25 +88,23 @@ export const checkChainOfTrust = ( /** * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0061.md#execution-8 */ -export const getNextCertificateFromAggregateCommits = ( - blockHeaders: BlockHeader[], - aggregateCommits: AggregateCommit[], - validatorsHashPreimage: ValidatorsData[], +export const getNextCertificateFromAggregateCommits = async ( + db: ChainConnectorDB, bftHeights: BFTHeights, lastCertificate: LastCertificate, -): Certificate | undefined => { - const blockHeaderAtLastCertifiedHeight = blockHeaders.find( - header => header.height === lastCertificate.height, - ); +): Promise => { + const blockHeaderAtLastCertifiedHeight = await db.getBlockHeaderByHeight(lastCertificate.height); + if (!blockHeaderAtLastCertifiedHeight) { throw new Error( `No block header found for the last certified height ${lastCertificate.height}.`, ); } - const validatorDataAtLastCertifiedHeight = validatorsHashPreimage.find(data => - data.validatorsHash.equals(blockHeaderAtLastCertifiedHeight?.validatorsHash), + const validatorDataAtLastCertifiedHeight = await db.getValidatorsDataByHash( + blockHeaderAtLastCertifiedHeight?.validatorsHash, ); + if (!validatorDataAtLastCertifiedHeight) { throw new Error( `No validatorsHash preimage data present for the given validatorsHash ${blockHeaderAtLastCertifiedHeight?.validatorsHash.toString( @@ -136,22 +123,32 @@ export const getNextCertificateFromAggregateCommits = ( while (height > lastCertificate.height) { // eslint-disable-next-line no-loop-func - const aggregateCommitAtHeight = aggregateCommits.find(a => a.height === height); + const aggregateCommitAtHeight = await db.getAggregateCommitByHeight(height); if (aggregateCommitAtHeight !== undefined) { // Verify whether the chain of trust is maintained, i.e., the certificate corresponding to // aggregateCommits[h] would be accepted by blockchain B. - const valid = checkChainOfTrust( + const valid = await checkChainOfTrust( blockHeaderAtLastCertifiedHeight.validatorsHash, blsKeyToBFTWeight, validatorDataAtLastCertifiedHeight.certificateThreshold, aggregateCommitAtHeight, - blockHeaders, - validatorsHashPreimage, + db, ); if (valid) { - return getCertificateFromAggregateCommit(aggregateCommitAtHeight, blockHeaders); + const blockHeaderAtAggregateCommitHeight = await db.getBlockHeaderByHeight( + aggregateCommitAtHeight.height, + ); + if (!blockHeaderAtAggregateCommitHeight) { + throw new Error( + `Block header not found for the given aggregate commit for height: ${aggregateCommitAtHeight.height}`, + ); + } + return getCertificateFromAggregateCommitByBlockHeader( + aggregateCommitAtHeight, + blockHeaderAtAggregateCommitHeight, + ); } } diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_api_client.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_api_client.ts new file mode 100644 index 0000000000..630b9f7ca9 --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_api_client.ts @@ -0,0 +1,203 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + BFTHeights, + ChainAccount, + ChainAccountJSON, + ChannelData, + ChannelDataJSON, + EventCallback, + JSONObject, + apiClient, + cryptography, + chain, + TransactionJSON, +} from 'lisk-sdk'; +import { + bftParametersJSONToObj, + chainAccountDataJSONToObj, + channelDataJSONToObj, + getTokenIDLSK, + proveResponseJSONToObj, +} from './utils'; +import { + BFTParametersJSON, + BFTParametersWithoutGeneratorKey, + Logger, + ModulesMetadata, + NodeInfo, + ProveResponseJSON, +} from './types'; + +const { address } = cryptography; +interface APIConfig { + wsConnectionString?: string; + ipcPath?: string; + logger: Logger; +} + +export class ChainAPIClient { + public chainID!: Buffer; + private readonly _config: APIConfig; + private _client!: apiClient.APIClient; + + public constructor(config: APIConfig) { + this._config = config; + } + + public async connect(client?: apiClient.APIClient) { + if (client) { + this._client = client; + + return; + } + if (!this._config.ipcPath && !this._config.wsConnectionString) { + throw new Error('IPC path and WS url are undefined in the configuration.'); + } + if (this._config.ipcPath) { + this._client = await apiClient.createIPCClient(this._config.ipcPath); + } else if (this._config.wsConnectionString) { + this._client = await apiClient.createWSClient(this._config.wsConnectionString); + } + + this.chainID = Buffer.from((await this.getNodeInfo()).chainID, 'hex'); + } + + public async disconnect() { + await this._client.disconnect(); + } + + public subscribe(eventName: string, cb: EventCallback): void { + this._client?.subscribe(eventName, cb); + } + + public async postTransaction(txBytes: Buffer): Promise<{ transactionId: string }> { + const result = await this._client?.invoke<{ + transactionId: string; + }>('txpool_postTransaction', { + transaction: txBytes.toString('hex'), + }); + + return result as { transactionId: string }; + } + + public async getTransactionByID(id: string): Promise { + const result = await this._client?.invoke('chain_getTransactionByID', { + id, + }); + + return result; + } + + public async getAuthAccountNonceFromPublicKey(publicKey: Buffer): Promise { + return ( + await this._client.invoke<{ nonce: string }>('auth_getAuthAccount', { + address: address.getLisk32AddressFromPublicKey(publicKey), + }) + ).nonce; + } + + public async getNodeInfo(): Promise { + return this._client.node.getNodeInfo(); + } + + public async getChannelAccount(chainID: Buffer): Promise { + const channelAccount = await this._client.invoke( + 'interoperability_getChannel', + { + chainID: chainID.toString('hex'), + }, + ); + if (!channelAccount || channelAccount?.inbox === undefined) { + return undefined; + } + + return channelDataJSONToObj(channelAccount); + } + + public async getChainAccount(chainID: Buffer): Promise { + const chainAccount = await this._client.invoke( + 'interoperability_getChainAccount', + { + chainID: chainID.toString('hex'), + }, + ); + + if (!chainAccount || chainAccount?.lastCertificate === undefined) { + return undefined; + } + return chainAccountDataJSONToObj(chainAccount); + } + + public async hasUserTokenAccount(userAddress: string) { + return this._client.invoke<{ exists: boolean }>('token_hasUserAccount', { + address: userAddress, + // It is always LSK token + tokenID: `${getTokenIDLSK(this.chainID).toString('hex')}`, + }); + } + + public async getTokenInitializationFee() { + return this._client.invoke<{ + userAccount: string; + escrowAccount: string; + }>('token_getInitializationFees'); + } + + public async getBFTHeights() { + return this._client.invoke('consensus_getBFTHeights'); + } + + public async getEvents(height: number) { + return this._client.invoke>('chain_getEvents', { height }); + } + + public async getMetadataByModuleName(moduleName: string) { + const { modules: modulesMetadata } = await this._client.invoke<{ + modules: ModulesMetadata; + }>('system_getMetadata'); + const moduleMetadata = modulesMetadata.find(m => m.name === moduleName); + + if (!moduleMetadata) { + throw new Error(`No metadata found for ${moduleName} module.`); + } + + return moduleMetadata; + } + + public async getInclusionProof(queryKeys: Buffer[]) { + return proveResponseJSONToObj( + await this._client.invoke('state_prove', { + queryKeys: [...queryKeys].map(k => k.toString('hex')), + }), + ); + } + + public async getSavedInclusionProofAtHeight(height: number) { + return proveResponseJSONToObj( + await this._client.invoke('chain_getInclusionProofsAtHeight', { + height, + }), + ); + } + + public async getBFTParametersAtHeight(height: number): Promise { + return bftParametersJSONToObj( + await this._client.invoke('consensus_getBFTParametersActiveValidators', { + height, + }), + ); + } +} diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_connector_plugin.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_connector_plugin.ts index 03d548f4c2..07964151b4 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_connector_plugin.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/chain_connector_plugin.ts @@ -12,147 +12,71 @@ * Removal or modification of this copyright notice is prohibited. */ -import { - BasePlugin, - PluginInitContext, - apiClient, - BFTHeights, - db as liskDB, - codec, - chain, - OutboxRootWitness, - JSONObject, - Schema, - Transaction, - LastCertificate, - CcmSendSuccessEventData, - CcmProcessedEventData, - CCMProcessedResult, - CrossChainUpdateTransactionParams, - certificateSchema, - ccuParamsSchema, - cryptography, - ChainAccountJSON, - ActiveValidatorsUpdate, - AggregateCommit, - ChannelDataJSON, - Certificate, - transactions, -} from 'lisk-sdk'; -import { calculateActiveValidatorsUpdate } from './active_validators_update'; -import { - getCertificateFromAggregateCommit, - getNextCertificateFromAggregateCommits, -} from './certificate_generation'; -import { - CCU_FREQUENCY, - MODULE_NAME_INTEROPERABILITY, - CCM_SEND_SUCCESS, - COMMAND_NAME_SUBMIT_SIDECHAIN_CCU, - CCM_PROCESSED, - EMPTY_BYTES, - COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, - CCU_TOTAL_CCM_SIZE, - DEFAULT_LAST_CCM_SENT_NONCE, -} from './constants'; -import { ChainConnectorStore, getDBInstance } from './db'; -import { Endpoint } from './endpoint'; +import { BasePlugin, PluginInitContext } from 'lisk-sdk'; +import { CCU_TOTAL_CCM_SIZE } from './constants'; import { configSchema } from './schemas'; -import { - ChainConnectorPluginConfig, - BlockHeader, - ProveResponseJSON, - BFTParametersJSON, - ValidatorsData, - LastSentCCMWithHeight, - CCMsFromEvents, -} from './types'; -import { calculateMessageWitnesses } from './inbox_update'; -import { - bftParametersJSONToObj, - chainAccountDataJSONToObj, - channelDataJSONToObj, - getMainchainID, - getTokenIDLSK, - proveResponseJSONToObj, -} from './utils'; - -const { address, ed } = cryptography; - -interface Data { - readonly blockHeader: chain.BlockHeaderJSON; -} - -type ModulesMetadata = [ - { - stores: { key: string; data: Schema }[]; - events: { name: string; data: Schema }[]; - name: string; - }, -]; - -type FinalizedHeightInfo = { inboxSize: number; lastCertificateHeight: number }; +import { ChainConnectorPluginConfig } from './types'; +import { ChainAPIClient } from './chain_api_client'; +import { BlockEventHandler } from './block_event_handler'; +import { ChainConnectorDB } from './db'; +import { ChainConnectorEndpoint } from './endpoint'; export class ChainConnectorPlugin extends BasePlugin { - public endpoint = new Endpoint(); + public readonly endpoint = new ChainConnectorEndpoint(); public configSchema = configSchema; - private _chainConnectorPluginDB!: liskDB.Database; - private _chainConnectorStore!: ChainConnectorStore; - private _lastCertificate!: LastCertificate; - private _ccuFrequency!: number; - private _maxCCUSize!: number; - private _isSaveCCU!: boolean; - private _receivingChainClient!: apiClient.APIClient; - private _sendingChainClient!: apiClient.APIClient; + private readonly _chainConnectorDB = new ChainConnectorDB(); + private _receivingChainClient!: ChainAPIClient; + private _sendingChainClient!: ChainAPIClient; private _ownChainID!: Buffer; private _receivingChainID!: Buffer; - private _isReceivingChainMainchain!: boolean; - private _registrationHeight!: number; - private _ccuSaveLimit!: number; - private _receivingChainFinalizedHeight!: number; - private _heightToDeleteIndex!: Map; + private _blockEventHandler!: BlockEventHandler; public get nodeModulePath(): string { return __filename; } - // eslint-disable-next-line @typescript-eslint/require-await public async init(context: PluginInitContext): Promise { await super.init(context); - this._ccuFrequency = this.config.ccuFrequency ?? CCU_FREQUENCY; if (this.config.maxCCUSize > CCU_TOTAL_CCM_SIZE) { throw new Error(`maxCCUSize cannot be greater than ${CCU_TOTAL_CCM_SIZE} bytes.`); } this._receivingChainID = Buffer.from(this.config.receivingChainID, 'hex'); - this._maxCCUSize = this.config.maxCCUSize; - this._isSaveCCU = this.config.isSaveCCU; - this._registrationHeight = this.config.registrationHeight; - this._ccuSaveLimit = this.config.ccuSaveLimit; - this._receivingChainFinalizedHeight = 0; - this._heightToDeleteIndex = new Map(); + this._blockEventHandler = new BlockEventHandler({ + maxCCUSize: this.config.maxCCUSize, + ownChainID: Buffer.from(this.appConfig.genesis.chainID, 'hex'), + receivingChainID: Buffer.from(this.config.receivingChainID, 'hex'), + registrationHeight: this.config.registrationHeight, + ccuFee: this.config.ccuFee, + isSaveCCU: this.config.isSaveCCU, + ccuSaveLimit: this.config.ccuSaveLimit, + }); + this._sendingChainClient = new ChainAPIClient({ + ipcPath: this.appConfig.system.dataPath, + logger: this.logger, + }); } public async load(): Promise { - this._chainConnectorPluginDB = await getDBInstance(this.dataPath); - this._chainConnectorStore = new ChainConnectorStore(this._chainConnectorPluginDB); - this.endpoint.load(this.config, this._chainConnectorStore); + await this._chainConnectorDB.load(this.dataPath); + this.endpoint.load(this.config.encryptedPrivateKey, this._chainConnectorDB); - this._sendingChainClient = this.apiClient; + await this._sendingChainClient.connect(this.apiClient); this._ownChainID = Buffer.from(this.appConfig.genesis.chainID, 'hex'); if (this._receivingChainID[0] !== this._ownChainID[0]) { throw new Error('Receiving Chain ID network does not match the sending chain network.'); } - // If the running node is mainchain then receiving chain will be sidechain or vice verse. - this._isReceivingChainMainchain = !getMainchainID(this._ownChainID).equals(this._ownChainID); - // On a new block start with CCU creation process - this._sendingChainClient.subscribe('chain_newBlock', async (data?: Record) => - this._newBlockHandler(data), - ); - this._sendingChainClient.subscribe( - 'chain_deleteBlock', - async (data?: Record) => this._deleteBlockHandler(data), - ); + this._receivingChainClient = new ChainAPIClient({ + logger: this.logger, + ipcPath: this.config.receivingChainIPCPath, + wsConnectionString: this.config.receivingChainWsURL, + }); + await this._blockEventHandler.load({ + db: this._chainConnectorDB, + logger: this.logger, + receivingChainAPIClient: this._receivingChainClient, + sendingChainAPIClient: this._sendingChainClient, + }); } public async unload(): Promise { @@ -163,771 +87,6 @@ export class ChainConnectorPlugin extends BasePlugin await this._sendingChainClient.disconnect(); } - this._chainConnectorStore.close(); - } - - public async _getCcuFee(tx: Record): Promise { - let additionalFee = BigInt(0); - - const userBalance = await this._receivingChainClient.invoke<{ exists: boolean }>( - 'token_hasUserAccount', - { - address: address.getLisk32AddressFromAddress( - address.getAddressFromPublicKey(tx.senderPublicKey as Buffer), - ), - // It is always LSK token - tokenID: `${getTokenIDLSK(this._receivingChainID).toString('hex')}`, - }, - ); - - if (!userBalance.exists) { - const fee = await this._receivingChainClient.invoke<{ - userAccount: string; - escrowAccount: string; - }>('token_getInitializationFees'); - additionalFee += BigInt(fee.userAccount); - } - - const ccuFee = BigInt(this.config.ccuFee ?? '0') + additionalFee; - const computedMinFee = transactions.computeMinFee(tx, ccuParamsSchema, { - additionalFee, - }); - - if (ccuFee > computedMinFee) { - return ccuFee; - } - return computedMinFee; - } - - private async _newBlockReceivingChainHandler(_?: Record) { - try { - const { finalizedHeight } = await this._receivingChainClient.invoke<{ - finalizedHeight: number; - }>('system_getNodeInfo'); - this._receivingChainFinalizedHeight = finalizedHeight; - const { inbox } = await this._receivingChainClient.invoke( - 'interoperability_getChannel', - { chainID: this._ownChainID.toString('hex') }, - ); - if (!inbox) { - throw new Error('No channel data available on receiving chain.'); - } - const { lastCertificate } = await this._receivingChainClient.invoke( - 'interoperability_getChainAccount', - { chainID: this._ownChainID.toString('hex') }, - ); - if (!lastCertificate) { - throw new Error('No chain data available on receiving chain.'); - } - this._heightToDeleteIndex.set(finalizedHeight, { - inboxSize: inbox.size, - lastCertificateHeight: lastCertificate.height, - }); - } catch (error) { - this.logger.debug( - error, - 'No Channel or Chain Data: Sending chain is not registered yet on receiving chain.', - ); - } - - await this._cleanup(); - } - - /** - * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0053.md - * This function is a handler for a new block. It saves all the relevant needed to be stored for each block that will be used to calculate CCU params - * - Calls _computeCCUParams that calculates CCU params - * - Saves or sends a CCU if created - * - Updates the last certificate and does the cleanup - */ - private async _newBlockHandler(data?: Record) { - const { blockHeader: receivedBlock } = data as unknown as Data; - - const newBlockHeader = chain.BlockHeader.fromJSON(receivedBlock).toObject(); - let chainAccountJSON: ChainAccountJSON; - // Save blockHeader, aggregateCommit, validatorsData and cross chain messages if any. - try { - const nodeInfo = await this._sendingChainClient.node.getNodeInfo(); - // Fetch last certificate from the receiving chain and update the _lastCertificate - try { - chainAccountJSON = await this._receivingChainClient.invoke( - 'interoperability_getChainAccount', - { chainID: this._ownChainID.toString('hex') }, - ); - // If sending chain is not registered with the receiving chain then only save data on new block and exit - if (!chainAccountJSON || (chainAccountJSON && !chainAccountJSON.lastCertificate)) { - this.logger.info( - 'Sending chain is not registered to the receiving chain yet and has no chain data.', - ); - await this._saveDataOnNewBlock(newBlockHeader); - - return; - } - } catch (error) { - // If receivingChainAPIClient is not ready then still save data on new block - await this._saveDataOnNewBlock(newBlockHeader); - await this._initializeReceivingChainClient(); - this.logger.error( - { err: error as Error }, - 'Error occurred while accessing receivingChainAPIClient but all data is saved on newBlock.', - ); - - return; - } - - this._lastCertificate = chainAccountDataJSONToObj(chainAccountJSON).lastCertificate; - const { aggregateCommits, blockHeaders, validatorsHashPreimage, crossChainMessages } = - await this._saveDataOnNewBlock(newBlockHeader); - - const numOfBlocksSinceLastCertificate = newBlockHeader.height - this._lastCertificate.height; - if (nodeInfo.syncing || this._ccuFrequency > numOfBlocksSinceLastCertificate) { - this.logger.debug( - { - syncing: nodeInfo.syncing, - ccuFrequency: this._ccuFrequency, - nextPossibleCCUHeight: this._ccuFrequency - numOfBlocksSinceLastCertificate, - }, - 'No attempt to create CCU either due to ccuFrequency or the node is syncing', - ); - - return; - } - // When all the relevant data is saved successfully then try to create CCU - const computedCCUParams = await this._computeCCUParams( - blockHeaders, - aggregateCommits, - validatorsHashPreimage, - crossChainMessages, - ); - - if (computedCCUParams) { - try { - await this._submitCCU(computedCCUParams.ccuParams); - // If CCU was sent successfully then save the lastSentCCM if any - // TODO: Add function to check on the receiving chain whether last sent CCM was accepted or not - if (computedCCUParams.lastCCMToBeSent) { - await this._chainConnectorStore.setLastSentCCM(computedCCUParams.lastCCMToBeSent); - } - } catch (error) { - this.logger.info( - { err: error }, - `Error occured while submitting CCU for the blockHeader at height: ${newBlockHeader.height}`, - ); - return; - } - } - } catch (error) { - this.logger.error(error, 'Failed while handling the new block'); - } - } - - /** - * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0053.md#cross-chain-update-transaction-properties - * This function computes CCU params especially, certificate, activeValidatorsUpdate and inboxUpdate - * - Uses either lastCertificate or newCertificate - * - When lastCertificate, it only computes pending CCMs if any else it skips CCU creation - * - When newCertificate it computes certificate, activeValidatorsUpdate and inboxUpdate - */ - - private async _computeCCUParams( - blockHeaders: BlockHeader[], - aggregateCommits: AggregateCommit[], - validatorsHashPreimage: ValidatorsData[], - ccmsFromEvents: CCMsFromEvents[], - ): Promise< - | { - ccuParams: CrossChainUpdateTransactionParams; - lastCCMToBeSent: LastSentCCMWithHeight | undefined; - } - | undefined - > { - const newCertificate = await this._findNextCertificate( - aggregateCommits, - blockHeaders, - validatorsHashPreimage, - ); - - if (!newCertificate && this._lastCertificate.height === 0) { - return; - } - /** - * If no lastSentCCM then assume that it's the first CCM to be sent - * and we can use the lastCertificate height - * which will be zero in case if this is the first CCU after registration - */ - const lastSentCCM = (await this._chainConnectorStore.getLastSentCCM()) ?? { - nonce: DEFAULT_LAST_CCM_SENT_NONCE, - height: this._lastCertificate.height, - }; - - let activeValidatorsUpdate: ActiveValidatorsUpdate = { - blsKeysUpdate: [], - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: EMPTY_BYTES, - }; - let certificate = EMPTY_BYTES; - let certificateThreshold; - let outboxRootWitness; - - // Take range from lastSentCCM height until new or last certificate height - const ccmsToBeIncluded = ccmsFromEvents.filter( - record => - record.height >= lastSentCCM.height && - // If no newCertificate then use lastCertificate height - record.height <= (newCertificate ? newCertificate.height : this._lastCertificate.height), - ); - // Calculate messageWitnessHashes for pending CCMs if any - const channelDataOnReceivingChain = await this._receivingChainClient.invoke( - 'interoperability_getChannel', - { chainID: this._ownChainID.toString('hex') }, - ); - if (!channelDataOnReceivingChain?.inbox) { - this.logger.info('Receiving chain is not registered yet on the sending chain.'); - return; - } - const inboxSizeOnReceivingChain = channelDataJSONToObj(channelDataOnReceivingChain).inbox.size; - - const receivingChainChannelDataJSON = await this._sendingChainClient.invoke( - 'interoperability_getChannel', - { chainID: this._receivingChainID.toString('hex') }, - ); - - if (!receivingChainChannelDataJSON?.outbox) { - this.logger.info('Sending chain is not registered yet on the receiving chain.'); - return; - } - const outboxSizeOnSendingChain = channelDataJSONToObj(receivingChainChannelDataJSON).outbox - .size; - const messageWitnessHashesForCCMs = calculateMessageWitnesses( - inboxSizeOnReceivingChain, - outboxSizeOnSendingChain, - lastSentCCM, - ccmsToBeIncluded, - this._maxCCUSize, - ); - const { crossChainMessages, lastCCMToBeSent, messageWitnessHashes } = - messageWitnessHashesForCCMs; - /** - * If there is no new certificate then we calculate CCU params based on last certificate and pending ccms - */ - if (!newCertificate) { - if (crossChainMessages.length === 0) { - this.logger.info( - 'CCU cant be created as there are no pending CCMs for the last certificate.', - ); - return; - } - // Empty outboxRootWitness for last certificate - outboxRootWitness = { - bitmap: EMPTY_BYTES, - siblingHashes: [], - }; - - // Use the old certificateThreshold - const validatorsDataAtLastCertificate = validatorsHashPreimage.find(validatorsData => - validatorsData.validatorsHash.equals(this._lastCertificate.validatorsHash), - ); - if (!validatorsDataAtLastCertificate) { - throw new Error('No validatorsData found for the lastCertificate.'); - } - - certificateThreshold = validatorsDataAtLastCertificate.certificateThreshold; - } else { - if (!this._lastCertificate.validatorsHash.equals(newCertificate.validatorsHash)) { - const validatorsUpdateResult = calculateActiveValidatorsUpdate( - newCertificate, - validatorsHashPreimage, - this._lastCertificate, - ); - activeValidatorsUpdate = validatorsUpdateResult.activeValidatorsUpdate; - certificateThreshold = validatorsUpdateResult.certificateThreshold; - } else { - // If there was no activeValidatorsUpdate then use the old certificateThreshold - const validatorsDataAtLastCertificate = validatorsHashPreimage.find(validatorsData => - validatorsData.validatorsHash.equals(this._lastCertificate.validatorsHash), - ); - if (!validatorsDataAtLastCertificate) { - throw new Error('No validatorsData found for the lastCertificate.'); - } - certificateThreshold = validatorsDataAtLastCertificate.certificateThreshold; - } - - // Get the inclusionProof for outboxRoot on stateRoot - const ccmsDataAtCertificateHeight = ccmsToBeIncluded.find( - ccmsData => ccmsData.height === newCertificate.height, - ); - if (crossChainMessages.length === 0) { - outboxRootWitness = { - bitmap: EMPTY_BYTES, - siblingHashes: [], - }; - } else { - outboxRootWitness = ccmsDataAtCertificateHeight?.inclusionProof; - } - - certificate = codec.encode(certificateSchema, newCertificate); - } - - // eslint-disable-next-line consistent-return - return { - ccuParams: { - sendingChainID: this._ownChainID, - activeValidatorsUpdate, - certificate, - certificateThreshold, - inboxUpdate: { - crossChainMessages, - messageWitnessHashes, - outboxRootWitness, - }, - } as CrossChainUpdateTransactionParams, - lastCCMToBeSent, - }; - } - - private async _findNextCertificate( - aggregateCommits: AggregateCommit[], - blockHeaders: BlockHeader[], - validatorsHashPreimage: ValidatorsData[], - ): Promise { - if (aggregateCommits.length === 0) { - return undefined; - } - - if (this._lastCertificate.height === 0) { - for (const aggregateCommit of aggregateCommits) { - // If blockHeader corresponding to aggregateCommit height does not exist then try with the next aggregCommit. - const blockHeaderExist = blockHeaders.find( - header => header.height === aggregateCommit.height, - ); - if (!blockHeaderExist || aggregateCommit.height < this._registrationHeight) { - continue; - } - - // When we receive the first aggregateCommit in the chain we can create certificate directly - const firstCertificate = getCertificateFromAggregateCommit(aggregateCommit, blockHeaders); - - return firstCertificate; - } - - return undefined; - } - const bftHeights = await this._sendingChainClient.invoke('consensus_getBFTHeights'); - // Calculate certificate - return getNextCertificateFromAggregateCommits( - blockHeaders, - aggregateCommits, - validatorsHashPreimage, - bftHeights, - this._lastCertificate, - ); - } - - /** - * This function saves block header, aggregateCommit, validatorsHashPreimage and crossChainMessages for a new block - */ - private async _saveDataOnNewBlock(newBlockHeader: BlockHeader) { - // Save block header if a new block header arrives - const blockHeaders = await this._chainConnectorStore.getBlockHeaders(); - - const blockHeaderIndex = blockHeaders.findIndex( - header => header.height === newBlockHeader.height, - ); - if (blockHeaderIndex > -1) { - blockHeaders[blockHeaderIndex] = newBlockHeader; - } else { - blockHeaders.push(newBlockHeader); - } - - // Check for events if any and store them - const events = await this._sendingChainClient.invoke>( - 'chain_getEvents', - { height: newBlockHeader.height }, - ); - - const { modules: modulesMetadata } = await this._sendingChainClient.invoke<{ - modules: ModulesMetadata; - }>('system_getMetadata'); - const interoperabilityMetadata = modulesMetadata.find( - m => m.name === MODULE_NAME_INTEROPERABILITY, - ); - - if (!interoperabilityMetadata) { - throw new Error(`No metadata found for ${MODULE_NAME_INTEROPERABILITY} module.`); - } - - const ccmsFromEvents = []; - // eslint-disable-next-line no-restricted-syntax, no-labels - ccmInEventsCheck: if (events && events.length > 0) { - const ccmSendSuccessEvents = events.filter( - eventAttr => - eventAttr.name === CCM_SEND_SUCCESS && eventAttr.module === MODULE_NAME_INTEROPERABILITY, - ); - - const ccmProcessedEvents = events.filter( - eventAttr => - eventAttr.name === CCM_PROCESSED && eventAttr.module === MODULE_NAME_INTEROPERABILITY, - ); - - if (ccmSendSuccessEvents.length === 0 && ccmProcessedEvents.length === 0) { - // If there are no CCMs present in the events for the height then skip CCM saving part - // eslint-disable-next-line no-labels - break ccmInEventsCheck; - } - - // Save ccm send success events - if (ccmSendSuccessEvents.length > 0) { - const ccmSendSuccessEventInfo = interoperabilityMetadata.events.filter( - e => e.name === CCM_SEND_SUCCESS, - ); - - if (!ccmSendSuccessEventInfo?.[0]?.data) { - throw new Error('No schema found for "ccmSendSuccess" event data.'); - } - - for (const e of ccmSendSuccessEvents) { - const eventData = codec.decode( - ccmSendSuccessEventInfo[0].data, - Buffer.from(e.data, 'hex'), - ); - ccmsFromEvents.push(eventData.ccm); - } - } - // Save ccm processed events based on CCMProcessedResult FORWARDED = 1 - if (ccmProcessedEvents.length > 0) { - const ccmProcessedEventInfo = interoperabilityMetadata.events.filter( - e => e.name === CCM_PROCESSED, - ); - - if (!ccmProcessedEventInfo?.[0]?.data) { - throw new Error('No schema found for "ccmProcessed" event data.'); - } - - for (const e of ccmProcessedEvents) { - const eventData = codec.decode( - ccmProcessedEventInfo[0].data, - Buffer.from(e.data, 'hex'), - ); - if (eventData.result === CCMProcessedResult.FORWARDED) { - ccmsFromEvents.push(eventData.ccm); - } - } - } - } - // TODO: find a better way to find storeKey from metadata - const store = interoperabilityMetadata.stores.find( - s => s.data.$id === '/modules/interoperability/outbox', - ); - - // Calculate the inclusion proof of the outbox root on state root - const outboxKey = Buffer.concat([ - Buffer.from(store?.key as string, 'hex'), - cryptography.utils.hash(this._receivingChainID), - ]).toString('hex'); - - const proveResponseJSON = await this._sendingChainClient.invoke( - 'state_prove', - { - queryKeys: [outboxKey], - }, - ); - const proveResponseObj = proveResponseJSONToObj(proveResponseJSON); - const outboxRootWitness: OutboxRootWitness = { - bitmap: proveResponseObj.proof.queries[0].bitmap, - siblingHashes: proveResponseObj.proof.siblingHashes, - }; - const crossChainMessages = await this._chainConnectorStore.getCrossChainMessages(); - let receivingChainOutboxSize = 0; - try { - const receivingChainChannelDataJSON = await this._sendingChainClient.invoke( - 'interoperability_getChannel', - { chainID: this._receivingChainID.toString('hex') }, - ); - receivingChainOutboxSize = receivingChainChannelDataJSON.outbox.size; - } catch (error) { - this.logger.debug( - error, - 'No Channel Data: Receiving chain is not registered yet on sending chain', - ); - } - crossChainMessages.push({ - ccms: this._isReceivingChainMainchain - ? ccmsFromEvents - : ccmsFromEvents.filter(ccm => ccm.receivingChainID.equals(this._receivingChainID)), - height: newBlockHeader.height, - inclusionProof: outboxRootWitness, - // Add outbox size info to be used for cleanup - outboxSize: receivingChainOutboxSize, - }); - - await this._chainConnectorStore.setCrossChainMessages(crossChainMessages); - - // Save validatorsData for a new validatorsHash - const validatorsHashPreimage = await this._chainConnectorStore.getValidatorsHashPreimage(); - - // Get validatorsData at new block header height - const bftParametersJSON = await this._sendingChainClient.invoke( - 'consensus_getBFTParametersActiveValidators', - { height: newBlockHeader.height }, - ); - - const bftParametersObj = bftParametersJSONToObj(bftParametersJSON); - const validatorsDataIndex = validatorsHashPreimage.findIndex(v => - v.validatorsHash.equals(bftParametersObj.validatorsHash), - ); - // Save validatorsData if there is a new validatorsHash - if (validatorsDataIndex === -1) { - const activeValidators = bftParametersObj.validators; - validatorsHashPreimage.push({ - certificateThreshold: bftParametersObj.certificateThreshold, - validators: activeValidators, - validatorsHash: bftParametersObj.validatorsHash, - }); - } - - // Save aggregateCommit if present in the block header - const aggregateCommits = await this._chainConnectorStore.getAggregateCommits(); - if ( - !newBlockHeader.aggregateCommit.aggregationBits.equals(EMPTY_BYTES) || - !newBlockHeader.aggregateCommit.certificateSignature.equals(EMPTY_BYTES) - ) { - const aggregateCommitIndex = aggregateCommits.findIndex( - commit => commit.height === newBlockHeader.aggregateCommit.height, - ); - if (aggregateCommitIndex > -1) { - aggregateCommits[aggregateCommitIndex] = newBlockHeader.aggregateCommit; - } else { - aggregateCommits.push(newBlockHeader.aggregateCommit); - } - } - - // Save all the data - await this._chainConnectorStore.setBlockHeaders(blockHeaders); - await this._chainConnectorStore.setAggregateCommits(aggregateCommits); - await this._chainConnectorStore.setValidatorsHashPreimage(validatorsHashPreimage); - - return { - blockHeaders, - aggregateCommits, - validatorsHashPreimage, - crossChainMessages, - }; - } - - private async _deleteBlockHandler(data?: Record) { - const { blockHeader: receivedBlock } = data as unknown as Data; - - const deletedBlockHeader = chain.BlockHeader.fromJSON(receivedBlock).toObject(); - - // Delete ccmEvents for the height of blockHeader - const crossChainMessages = await this._chainConnectorStore.getCrossChainMessages(); - const indexForCCMEvents = crossChainMessages.findIndex( - ccm => ccm.height === deletedBlockHeader.height, - ); - crossChainMessages.splice(indexForCCMEvents, 1); - await this._chainConnectorStore.setCrossChainMessages(crossChainMessages); - - const findIndexByHeight = (someData: { height: number }[]): number => - someData.findIndex(datum => datum.height === deletedBlockHeader.height); - - const blockHeaders = await this._chainConnectorStore.getBlockHeaders(); - const blockHeaderIndex = findIndexByHeight(blockHeaders); - if (blockHeaderIndex !== -1) { - blockHeaders.splice(blockHeaderIndex, 1); - await this._chainConnectorStore.setBlockHeaders(blockHeaders); - } - - if ( - !deletedBlockHeader.aggregateCommit.aggregationBits.equals(EMPTY_BYTES) || - !deletedBlockHeader.aggregateCommit.certificateSignature.equals(EMPTY_BYTES) - ) { - const aggregateCommits = await this._chainConnectorStore.getAggregateCommits(); - const aggregateCommitIndex = aggregateCommits.findIndex( - commit => commit.height === deletedBlockHeader.aggregateCommit.height, - ); - if (aggregateCommitIndex > -1) { - aggregateCommits.splice(aggregateCommitIndex, 1); - } - await this._chainConnectorStore.setAggregateCommits(aggregateCommits); - } - - const validatorsHashPreimage = await this._chainConnectorStore.getValidatorsHashPreimage(); - const validatorsHashMap = blockHeaders.reduce((prev: Record, curr) => { - // eslint-disable-next-line no-param-reassign - prev[curr.validatorsHash.toString('hex')] = true; - return prev; - }, {}); - const updatedValidatorsHashPreimages = validatorsHashPreimage.filter( - vhp => validatorsHashMap[vhp.validatorsHash.toString('hex')], - ); - if (updatedValidatorsHashPreimages.length !== validatorsHashPreimage.length) { - await this._chainConnectorStore.setValidatorsHashPreimage(updatedValidatorsHashPreimages); - } - } - - private async _cleanup() { - // Delete CCUs - // When given -1 then there is no limit - if (this._ccuSaveLimit !== -1) { - const listOfCCUs = await this._chainConnectorStore.getListOfCCUs(); - if (listOfCCUs.length > this._ccuSaveLimit) { - await this._chainConnectorStore.setListOfCCUs( - // Takes the last ccuSaveLimit elements - listOfCCUs.slice(-this._ccuSaveLimit), - ); - } - let finalizedInfoAtHeight = this._heightToDeleteIndex.get( - this._receivingChainFinalizedHeight, - ); - if (!finalizedInfoAtHeight) { - for (let i = 1; i < this._heightToDeleteIndex.size; i += 1) { - if (this._heightToDeleteIndex.get(this._receivingChainFinalizedHeight - i)) { - finalizedInfoAtHeight = this._heightToDeleteIndex.get( - this._receivingChainFinalizedHeight - i, - ); - break; - } - } - } - - // Delete CCMs - const crossChainMessages = await this._chainConnectorStore.getCrossChainMessages(); - const ccmsAfterLastCertificate = crossChainMessages.filter( - ccm => - // Some extra ccms may be stored at the outbox size === finalizedheight.inboxSize - ccm.outboxSize >= (finalizedInfoAtHeight ? finalizedInfoAtHeight.inboxSize : 0), - ); - - await this._chainConnectorStore.setCrossChainMessages(ccmsAfterLastCertificate); - // Delete blockHeaders - const blockHeaders = await this._chainConnectorStore.getBlockHeaders(); - const updatedBlockHeaders = blockHeaders.filter( - blockHeader => - blockHeader.height >= - (finalizedInfoAtHeight ? finalizedInfoAtHeight.lastCertificateHeight : 0), - ); - await this._chainConnectorStore.setBlockHeaders(updatedBlockHeaders); - - // Delete aggregateCommits - const aggregateCommits = await this._chainConnectorStore.getAggregateCommits(); - - await this._chainConnectorStore.setAggregateCommits( - aggregateCommits.filter( - aggregateCommit => - aggregateCommit.height >= - (finalizedInfoAtHeight ? finalizedInfoAtHeight.lastCertificateHeight : 0), - ), - ); - // Delete validatorsHashPreimage - const validatorsHashPreimage = await this._chainConnectorStore.getValidatorsHashPreimage(); - const validatorsHashMap = updatedBlockHeaders.reduce( - (prev: Record, curr) => { - // eslint-disable-next-line no-param-reassign - prev[curr.validatorsHash.toString('hex')] = true; - return prev; - }, - {}, - ); - const updatedValidatorsHashPreimages = validatorsHashPreimage.filter( - vhp => validatorsHashMap[vhp.validatorsHash.toString('hex')], - ); - if (updatedValidatorsHashPreimages.length !== validatorsHashPreimage.length) { - await this._chainConnectorStore.setValidatorsHashPreimage(updatedValidatorsHashPreimages); - } - - // Delete info less than finalized height - this._heightToDeleteIndex.forEach((_, key) => { - if (key < this._receivingChainFinalizedHeight) { - this._heightToDeleteIndex.delete(key); - } - }); - } - } - - private async _submitCCU(ccuParams: CrossChainUpdateTransactionParams): Promise { - if (!this._chainConnectorStore.privateKey) { - throw new Error('There is no key enabled to submit CCU'); - } - const relayerPublicKey = ed.getPublicKeyFromPrivateKey(this._chainConnectorStore.privateKey); - const targetCommand = this._isReceivingChainMainchain - ? COMMAND_NAME_SUBMIT_MAINCHAIN_CCU - : COMMAND_NAME_SUBMIT_SIDECHAIN_CCU; - - const { nonce } = await this._receivingChainClient.invoke<{ nonce: string }>( - 'auth_getAuthAccount', - { - address: address.getLisk32AddressFromPublicKey(relayerPublicKey), - }, - ); - - const { chainID: chainIDStr } = await this._receivingChainClient.invoke<{ chainID: string }>( - 'system_getNodeInfo', - ); - const chainID = Buffer.from(chainIDStr, 'hex'); - - const txWithoutFee = { - module: MODULE_NAME_INTEROPERABILITY, - command: targetCommand, - nonce: BigInt(nonce), - senderPublicKey: relayerPublicKey, - params: codec.encode(ccuParamsSchema, ccuParams), - signatures: [], - }; - - const tx = new Transaction({ - ...txWithoutFee, - fee: await this._getCcuFee({ - ...txWithoutFee, - params: ccuParams, - }), - }); - - tx.sign(chainID, this._chainConnectorStore.privateKey); - let result: { transactionId: string }; - if (this._isSaveCCU) { - result = { transactionId: tx.id.toString('hex') }; - } else { - result = await this._receivingChainClient.invoke<{ - transactionId: string; - }>('txpool_postTransaction', { - transaction: tx.getBytes().toString('hex'), - }); - } - /** - * TODO: As of now we save it in memory but going forward it should be saved in DB, - * as the array size can grow after sometime. - */ - // Save the sent CCU - const listOfCCUs = await this._chainConnectorStore.getListOfCCUs(); - listOfCCUs.push(tx.toObject()); - await this._chainConnectorStore.setListOfCCUs(listOfCCUs); - // Update logs - this.logger.info({ transactionID: result.transactionId }, 'Sent CCU transaction'); - } - - private async _initializeReceivingChainClient() { - if (!this.config.receivingChainIPCPath && !this.config.receivingChainWsURL) { - throw new Error('IPC path and WS url are undefined in the configuration.'); - } - try { - if (this.config.receivingChainIPCPath) { - this._receivingChainClient = await apiClient.createIPCClient( - this.config.receivingChainIPCPath, - ); - } else if (this.config.receivingChainWsURL) { - this._receivingChainClient = await apiClient.createWSClient( - this.config.receivingChainWsURL, - ); - } - this._receivingChainClient.subscribe( - 'chain_newBlock', - async (data?: Record) => this._newBlockReceivingChainHandler(data), - ); - } catch (error) { - this.logger.error( - error, - 'Not able to connect to receivingChainAPIClient. Trying again on next new block.', - ); - } + this._chainConnectorDB.close(); } } diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/constants.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/constants.ts index a95cc836e1..6731adeb65 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/constants.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/constants.ts @@ -30,13 +30,15 @@ export const CHAIN_ID_LENGTH = 4; export const DEFAULT_REGISTRATION_HEIGHT = 1; export const DEFAULT_LAST_CCM_SENT_NONCE = BigInt(-1); export const DEFAULT_CCU_SAVE_LIMIT = 300; +export const DEFAULT_SENT_CCU_TIMEOUT = 3600000; // 1 hour -export const DB_KEY_CROSS_CHAIN_MESSAGES = Buffer.from([1]); -export const DB_KEY_BLOCK_HEADERS = Buffer.from([2]); -export const DB_KEY_AGGREGATE_COMMITS = Buffer.from([3]); -export const DB_KEY_VALIDATORS_HASH_PREIMAGE = Buffer.from([4]); -export const DB_KEY_LAST_SENT_CCM = Buffer.from([5]); -export const DB_KEY_LIST_OF_CCU = Buffer.from([6]); +export const DB_KEY_BLOCK_HEADER_BY_HEIGHT = Buffer.from([1]); +export const DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT = Buffer.from([2]); +export const DB_KEY_VALIDATORS_DATA_BY_HASH = Buffer.from([3]); +export const DB_KEY_VALIDATORS_DATA_BY_HEIGHT = Buffer.from([4]); +export const DB_KEY_CROSS_CHAIN_MESSAGES = Buffer.from([5]); +export const DB_KEY_LAST_SENT_CCM = Buffer.from([6]); +export const DB_KEY_LIST_OF_CCU = Buffer.from([7]); /** * It’s not really MAX_CCU_SIZE, coz CCU includes other properties diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/db.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/db.ts index 9c65068b07..1b92198138 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/db.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/db.ts @@ -12,47 +12,50 @@ * Removal or modification of this copyright notice is prohibited. */ -import { codec, db as liskDB, AggregateCommit, chain, cryptography } from 'lisk-sdk'; +import { + codec, + db as liskDB, + AggregateCommit, + chain, + cryptography, + EMPTY_BYTES, + aggregateCommitSchema, + CCMsg, + ccuParamsSchema, + certificateSchema, + Certificate, + ccmSchema, +} from 'lisk-sdk'; import * as os from 'os'; import { join } from 'path'; import { ensureDir } from 'fs-extra'; import { - DB_KEY_AGGREGATE_COMMITS, - DB_KEY_BLOCK_HEADERS, + DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, + DB_KEY_BLOCK_HEADER_BY_HEIGHT, DB_KEY_CROSS_CHAIN_MESSAGES, DB_KEY_LAST_SENT_CCM, DB_KEY_LIST_OF_CCU, - DB_KEY_VALIDATORS_HASH_PREIMAGE, + DB_KEY_VALIDATORS_DATA_BY_HASH, + DB_KEY_VALIDATORS_DATA_BY_HEIGHT, } from './constants'; import { - aggregateCommitsInfoSchema, - blockHeadersInfoSchema, - ccmsFromEventsSchema, - lastSentCCMWithHeight, - listOfCCUsSchema, - validatorsHashPreimageInfoSchema, + blockHeaderSchemaWithID, + ccmsAtHeightSchema, + lastSentCCMSchema, + transactionSchemaWithID, + validatorsDataSchema, } from './schemas'; -import { BlockHeader, CCMsFromEvents, LastSentCCMWithHeight, ValidatorsData } from './types'; +import { + BlockHeader, + CCMWithHeight, + CCUpdateParams, + LastSentCCM, + ValidatorsDataWithHeight, +} from './types'; const { Database } = liskDB; type KVStore = liskDB.Database; -interface BlockHeadersInfo { - blockHeaders: BlockHeader[]; -} - -interface AggregateCommitsInfo { - aggregateCommits: AggregateCommit[]; -} - -interface ValidatorsHashPreimage { - validatorsHashPreimage: ValidatorsData[]; -} - -interface CrossChainMessagesInfo { - ccmsFromEvents: CCMsFromEvents[]; -} - export const getDBInstance = async ( dataPath: string, dbName = 'lisk-framework-chain-connector-plugin.db', @@ -69,12 +72,19 @@ export const checkDBError = (error: Error | unknown) => { } }; -export class ChainConnectorStore { - private readonly _db: KVStore; +export const concatDBKeys = (...keys: Buffer[]) => Buffer.concat(keys); +export const uint32BE = (val: number): Buffer => { + const result = Buffer.alloc(4); + result.writeUInt32BE(val, 0); + return result; +}; + +export class ChainConnectorDB { + private _db!: KVStore; private _privateKey?: Buffer; - public constructor(db: KVStore) { - this._db = db; + public async load(dataPath: string) { + this._db = await getDBInstance(dataPath); } public close() { @@ -85,118 +95,476 @@ export class ChainConnectorStore { return this._privateKey; } - public async getBlockHeaders(): Promise { - let blockHeaders: BlockHeader[] = []; + public async saveToDBOnNewBlock(blockHeader: BlockHeader) { + const heightBuf = uint32BE(blockHeader.height); + const batch = new liskDB.Batch(); + const newBlockHeaderBytes = codec.encode(blockHeaderSchemaWithID, blockHeader); + + batch.set(concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, heightBuf), newBlockHeaderBytes); + + if ( + !blockHeader.aggregateCommit.aggregationBits.equals(EMPTY_BYTES) || + !blockHeader.aggregateCommit.certificateSignature.equals(EMPTY_BYTES) + ) { + const aggregateCommitHeight = uint32BE(blockHeader.aggregateCommit.height); + const aggregateCommitBytes = codec.encode(aggregateCommitSchema, blockHeader.aggregateCommit); + batch.set( + concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, aggregateCommitHeight), + aggregateCommitBytes, + ); + } + + await this._db.write(batch); + } + + public async getBlockHeaderByHeight(height: number): Promise { try { - const encodedInfo = await this._db.get(DB_KEY_BLOCK_HEADERS); - blockHeaders = codec.decode( - blockHeadersInfoSchema, - encodedInfo, - ).blockHeaders; + const blockBytes = await this._db.get( + concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, uint32BE(height)), + ); + + return codec.decode(blockHeaderSchemaWithID, blockBytes); } catch (error) { checkDBError(error); + + return undefined; } - return blockHeaders; } - public async setBlockHeaders(blockHeaders: BlockHeader[]) { - const encodedInfo = codec.encode(blockHeadersInfoSchema, { blockHeaders }); + public async getBlockHeadersBetweenHeights(fromHeight: number, toHeight: number) { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, uint32BE(toHeight)), + reverse: true, + }); + const blockHeaders = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ value }: { value: Buffer }) => { + list.push(value); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + return blockHeaders.map(b => codec.decode(blockHeaderSchemaWithID, b)); + } + + public async deleteBlockHeadersBetweenHeight( + fromHeight: number, + toHeight: number, + ): Promise { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, uint32BE(toHeight)), + reverse: true, + }); + const blockHeaderIndexes = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ key }: { key: Buffer }) => { + list.push(key); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + const batch = new liskDB.Batch(); + for (const key of blockHeaderIndexes) { + batch.del(key); + } + + await this._db.write(batch); + } - await this._db.set(DB_KEY_BLOCK_HEADERS, encodedInfo); + public async deleteBlockHeaderByHeight(height: number): Promise { + const heightBuf = uint32BE(height); + await this._db.del(concatDBKeys(DB_KEY_BLOCK_HEADER_BY_HEIGHT, heightBuf)); } - public async getAggregateCommits(): Promise { - let aggregateCommits: AggregateCommit[] = []; + public async getAggregateCommitByHeight(height: number) { try { - const encodedInfo = await this._db.get(DB_KEY_AGGREGATE_COMMITS); - aggregateCommits = codec.decode( - aggregateCommitsInfoSchema, - encodedInfo, - ).aggregateCommits; + const bytes = await this._db.get( + concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, uint32BE(height)), + ); + + return codec.decode(aggregateCommitSchema, bytes); } catch (error) { checkDBError(error); + + return undefined; + } + } + + public async getAggregateCommitBetweenHeights(fromHeight: number, toHeight: number) { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, uint32BE(toHeight)), + reverse: true, + }); + const aggregateCommits = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ value }: { value: Buffer }) => { + list.push(value); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + return aggregateCommits.map(a => codec.decode(aggregateCommitSchema, a)); + } + + public async deleteAggregateCommitsBetweenHeight( + fromHeight: number, + toHeight: number, + ): Promise { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, uint32BE(toHeight)), + reverse: true, + }); + const aggregateCommitIndexes = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ key }: { key: Buffer }) => { + list.push(key); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + const batch = new liskDB.Batch(); + for (const key of aggregateCommitIndexes) { + batch.del(key); } - return aggregateCommits; + + await this._db.write(batch); } - public async setAggregateCommits(aggregateCommits: AggregateCommit[]) { - const encodedInfo = codec.encode(aggregateCommitsInfoSchema, { aggregateCommits }); - await this._db.set(DB_KEY_AGGREGATE_COMMITS, encodedInfo); + public async deleteAggregateCommitByHeight(height: number): Promise { + const heightBuf = uint32BE(height); + await this._db.del(concatDBKeys(DB_KEY_AGGREGATE_COMMIT_BY_HEIGHT, heightBuf)); } - public async getValidatorsHashPreimage(): Promise { - let validatorsHashPreimage: ValidatorsData[] = []; + public async getValidatorsDataByHash(validatorsHash: Buffer) { try { - const encodedInfo = await this._db.get(DB_KEY_VALIDATORS_HASH_PREIMAGE); - validatorsHashPreimage = codec.decode( - validatorsHashPreimageInfoSchema, - encodedInfo, - ).validatorsHashPreimage; + const bytes = await this._db.get( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, validatorsHash), + ); + + return codec.decode(validatorsDataSchema, bytes); } catch (error) { checkDBError(error); + + return undefined; } - return validatorsHashPreimage; } - public async setValidatorsHashPreimage(validatorsHashInput: ValidatorsData[]) { - const encodedInfo = codec.encode(validatorsHashPreimageInfoSchema, { - validatorsHashPreimage: validatorsHashInput, + public async setValidatorsDataByHash( + validatorsHash: Buffer, + validatorsData: ValidatorsDataWithHeight, + height: number, + ) { + const bytes = codec.encode(validatorsDataSchema, validatorsData); + await this._db.set(concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, validatorsHash), bytes); + await this._db.set( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(height)), + validatorsHash, + ); + } + public async getValidatorsDataByHeight(height: number) { + try { + const validatorHash = await this._db.get( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(height)), + ); + + return this.getValidatorsDataByHash( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, validatorHash), + ); + } catch (error) { + checkDBError(error); + + return undefined; + } + } + + public async deleteValidatorsHashByHeight(height: number) { + const validatorHash = await this._db.get( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(height)), + ); + await this.deleteValidatorsDataByHash(concatDBKeys(validatorHash)); + } + + public async deleteValidatorsHashBetweenHeights(fromHeight: number, toHeight: number) { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(toHeight)), + reverse: true, + }); + const validatorsHashes = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ key }: { key: Buffer }) => { + list.push(key); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + for (const hash of validatorsHashes) { + await this.deleteValidatorsDataByHash(hash); + } + } + + public async deleteValidatorsDataByHash(validatorsHash: Buffer) { + const validatorData = await this.getValidatorsDataByHash(validatorsHash); + + if (!validatorData) { + return; + } + await this._db.del(concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, validatorsHash)); + await this._db.del( + concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HEIGHT, uint32BE(validatorData.height)), + ); + } + + public async getAllValidatorsData() { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, Buffer.alloc(4, 0)), + lte: concatDBKeys(DB_KEY_VALIDATORS_DATA_BY_HASH, Buffer.alloc(4, 255)), + reverse: true, }); - await this._db.set(DB_KEY_VALIDATORS_HASH_PREIMAGE, encodedInfo); + const validatorsData = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ value }: { value: Buffer }) => { + list.push(value); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + return validatorsData.map(v => codec.decode(validatorsDataSchema, v)); } - public async getCrossChainMessages(): Promise { - let crossChainMessages: CCMsFromEvents[] = []; + public async getCCMsByHeight(height: number): Promise { + const heightBuf = uint32BE(height); + let crossChainMessages: CCMsg[] = []; try { - const encodedInfo = await this._db.get(DB_KEY_CROSS_CHAIN_MESSAGES); - crossChainMessages = codec.decode( - ccmsFromEventsSchema, - encodedInfo, - ).ccmsFromEvents; + const encodedInfo = await this._db.get(concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, heightBuf)); + crossChainMessages = codec.decode<{ ccms: CCMsg[] }>(ccmsAtHeightSchema, encodedInfo).ccms; } catch (error) { checkDBError(error); } return crossChainMessages; } - public async setCrossChainMessages(ccms: CCMsFromEvents[]) { - const encodedInfo = codec.encode(ccmsFromEventsSchema, { ccmsFromEvents: ccms }); - await this._db.set(DB_KEY_CROSS_CHAIN_MESSAGES, encodedInfo); + public async deleteCCMsByHeight(height: number): Promise { + const heightBuf = uint32BE(height); + await this._db.del(concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, heightBuf)); } - public async getLastSentCCM(): Promise { - let lastSentCCM: LastSentCCMWithHeight | undefined; + public async getCCMsBetweenHeights( + fromHeight: number, + toHeight: number, + ): Promise { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, uint32BE(toHeight)), + reverse: true, + }); + const ccmArrayAtEachHeight = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ value }: { value: Buffer }) => { + list.push(value); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + const flatCCMList = []; + + for (const ccms of ccmArrayAtEachHeight) { + flatCCMList.push(...codec.decode<{ ccms: CCMWithHeight[] }>(ccmsAtHeightSchema, ccms).ccms); + } + + return flatCCMList; + } + + public async deleteCCMsBetweenHeight(fromHeight: number, toHeight: number): Promise { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, uint32BE(fromHeight)), + lte: concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, uint32BE(toHeight)), + reverse: true, + }); + const ccmsListIndexes = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ key }: { key: Buffer }) => { + list.push(key); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + const batch = new liskDB.Batch(); + for (const key of ccmsListIndexes) { + batch.del(key); + } + + await this._db.write(batch); + } + + public async setCCMsByHeight(ccms: CCMWithHeight[], height: number) { + const heightBuf = uint32BE(height); + + const encodedInfo = codec.encode(ccmsAtHeightSchema, { ccms }); + + await this._db.set(concatDBKeys(DB_KEY_CROSS_CHAIN_MESSAGES, heightBuf), encodedInfo); + } + + public async getLastSentCCM(): Promise { + let lastSentCCM: LastSentCCM | undefined; try { const encodedInfo = await this._db.get(DB_KEY_LAST_SENT_CCM); - lastSentCCM = codec.decode(lastSentCCMWithHeight, encodedInfo); + lastSentCCM = codec.decode(lastSentCCMSchema, encodedInfo); } catch (error) { checkDBError(error); + + return undefined; } return lastSentCCM; } - public async setLastSentCCM(lastSentCCM: LastSentCCMWithHeight) { - await this._db.set(DB_KEY_LAST_SENT_CCM, codec.encode(lastSentCCMWithHeight, lastSentCCM)); + public async setLastSentCCM(ccm: LastSentCCM) { + await this._db.set(DB_KEY_LAST_SENT_CCM, codec.encode(lastSentCCMSchema, ccm)); } - public async getListOfCCUs(): Promise { - let listOfCCUs: chain.TransactionAttrs[] = []; - try { - const encodedInfo = await this._db.get(DB_KEY_LIST_OF_CCU); - listOfCCUs = codec.decode<{ listOfCCUs: chain.TransactionAttrs[] }>( - listOfCCUsSchema, - encodedInfo, - ).listOfCCUs; - } catch (error) { - checkDBError(error); - } - return listOfCCUs; + public async getListOfCCUs(): Promise<{ list: Record[]; total: number }> { + const stream = this._db.createReadStream({ + gte: concatDBKeys(DB_KEY_LIST_OF_CCU, Buffer.alloc(4, 0)), + lte: concatDBKeys(DB_KEY_LIST_OF_CCU, Buffer.alloc(4, 255)), + reverse: true, + }); + const ccuBytes = await new Promise((resolve, reject) => { + const list: Buffer[] = []; + stream + .on('data', ({ value }: { value: Buffer }) => { + list.push(value); + }) + .on('error', error => { + reject(error); + }) + .on('end', () => { + resolve(list); + }); + }); + + return { + list: ccuBytes + .map(b => { + const tx = codec.decode(transactionSchemaWithID, b); + const txJSON = new chain.Transaction(tx).toJSON(); + const params = codec.decode(ccuParamsSchema, tx.params); + const certificate = codec.decode(certificateSchema, params.certificate); + + const inboxUpdate = { + crossChainMessages: params.inboxUpdate.crossChainMessages.map(ccmBytes => { + const ccm = codec.decode(ccmSchema, ccmBytes); + + return { + ...ccm, + fee: ccm.fee.toString(), + nonce: ccm.nonce.toString(), + params: ccm.params.toString('hex'), + receivingChainID: ccm.receivingChainID.toString('hex'), + sendingChainID: ccm.sendingChainID.toString('hex'), + }; + }), + messageWitnessHashes: params.inboxUpdate.messageWitnessHashes.map(h => + h.toString('hex'), + ), + outboxRootWitness: { + bitmap: params.inboxUpdate.outboxRootWitness.bitmap.toString('hex'), + siblingHashes: params.inboxUpdate.outboxRootWitness.siblingHashes.map(h => + h.toString('hex'), + ), + }, + }; + + const activeValidatorsUpdate = { + bftWeightsUpdate: params.activeValidatorsUpdate.bftWeightsUpdate.map(w => w.toString()), + bftWeightsUpdateBitmap: + params.activeValidatorsUpdate.bftWeightsUpdateBitmap.toString('hex'), + blsKeysUpdate: params.activeValidatorsUpdate.blsKeysUpdate.map(key => + key.toString('hex'), + ), + }; + + return { + ...txJSON, + params: { + activeValidatorsUpdate, + certificate: { + aggregationBits: certificate.aggregationBits.toString('hex'), + blockID: certificate.blockID.toString('hex'), + height: certificate.height, + signature: certificate.signature.toString('hex'), + stateRoot: certificate.stateRoot.toString('hex'), + timestamp: certificate.timestamp, + validatorsHash: certificate.validatorsHash.toString('hex'), + }, + inboxUpdate, + certificateThreshold: params.certificateThreshold.toString(), + sendingChainID: params.sendingChainID.toString('hex'), + }, + }; + }) + .sort((a, b) => Number(BigInt(b.nonce) - BigInt(a.nonce))), + total: ccuBytes.length, + }; } - public async setListOfCCUs(listOfCCUs: chain.TransactionAttrs[]) { - listOfCCUs.sort((a, b) => Number(b.nonce) - Number(a.nonce)); + public async setCCUTransaction(ccu: chain.TransactionAttrs) { + await this._db.set( + concatDBKeys(DB_KEY_LIST_OF_CCU, ccu?.id as Buffer), + codec.encode(transactionSchemaWithID, ccu), + ); + } - await this._db.set(DB_KEY_LIST_OF_CCU, codec.encode(listOfCCUsSchema, { listOfCCUs })); + public async deleteCCUTransaction(ccuID: Buffer) { + await this._db.del(concatDBKeys(DB_KEY_LIST_OF_CCU, ccuID)); } public async setPrivateKey(encryptedPrivateKey: string, password: string) { diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/endpoint.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/endpoint.ts index 3aaf8b3645..6e4400cd7f 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/endpoint.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/endpoint.ts @@ -15,66 +15,63 @@ import { BasePluginEndpoint, PluginEndpointContext, - chain, BlockHeader, BlockHeaderJSON, validator as liskValidator, } from 'lisk-sdk'; -import { ChainConnectorStore } from './db'; import { AggregateCommitJSON, - CCMsFromEventsJSON, - ChainConnectorPluginConfig, + CCMWithHeightJSON, LastSentCCMWithHeightJSON, - SentCCUsJSON, - ValidatorsDataJSON, + ValidatorsDataHeightJSON, } from './types'; -import { aggregateCommitToJSON, ccmsFromEventsToJSON, validatorsHashPreimagetoJSON } from './utils'; +import { aggregateCommitToJSON, ccmsWithHeightToJSON, validatorsHashPreimagetoJSON } from './utils'; import { authorizeRequestSchema } from './schemas'; +import { ChainConnectorDB } from './db'; // disabled for type annotation // eslint-disable-next-line prefer-destructuring const validator: liskValidator.LiskValidator = liskValidator.validator; -export class Endpoint extends BasePluginEndpoint { - private _chainConnectorStore!: ChainConnectorStore; - private _config!: ChainConnectorPluginConfig; +export class ChainConnectorEndpoint extends BasePluginEndpoint { + private db!: ChainConnectorDB; + private _encryptedPrivateKey!: string; - public load(config: ChainConnectorPluginConfig, store: ChainConnectorStore) { - this._config = config; - this._chainConnectorStore = store; + public load(encryptedPrivateKey: string, store: ChainConnectorDB) { + this._encryptedPrivateKey = encryptedPrivateKey; + this.db = store; } // eslint-disable-next-line @typescript-eslint/require-await - public async getSentCCUs(_context: PluginEndpointContext): Promise { - const sentCCUs = await this._chainConnectorStore.getListOfCCUs(); - return sentCCUs.map(transaction => new chain.Transaction(transaction).toJSON()); + public async getSentCCUs( + _context: PluginEndpointContext, + ): Promise<{ list: Record[]; total: number }> { + return this.db.getListOfCCUs(); } - public async getAggregateCommits( - _context: PluginEndpointContext, - ): Promise { - const aggregateCommits = await this._chainConnectorStore.getAggregateCommits(); + public async getAggregateCommits(context: PluginEndpointContext): Promise { + const { from, to } = context.params as { from: number; to: number }; + const aggregateCommits = await this.db.getAggregateCommitBetweenHeights(from, to); return aggregateCommits.map(aggregateCommit => aggregateCommitToJSON(aggregateCommit)); } - public async getBlockHeaders(_context: PluginEndpointContext): Promise { - const blockHeaders = await this._chainConnectorStore.getBlockHeaders(); + public async getBlockHeaders(context: PluginEndpointContext): Promise { + const { from, to } = context.params as { from: number; to: number }; + const blockHeaders = await this.db.getBlockHeadersBetweenHeights(from, to); return blockHeaders.map(blockHeader => new BlockHeader(blockHeader).toJSON()); } - public async getCrossChainMessages( - _context: PluginEndpointContext, - ): Promise { - const ccmsAndInclusionProofs = await this._chainConnectorStore.getCrossChainMessages(); - return ccmsAndInclusionProofs.map(ccmsAndInclusionProof => - ccmsFromEventsToJSON(ccmsAndInclusionProof), - ); + public async getCrossChainMessages(context: PluginEndpointContext): Promise { + const { from, to } = context.params as { from: number; to: number }; + + const ccms = await this.db.getCCMsBetweenHeights(from, to); + + return ccmsWithHeightToJSON(ccms); } public async getLastSentCCM(_context: PluginEndpointContext): Promise { - const lastSentCCM = await this._chainConnectorStore.getLastSentCCM(); + const lastSentCCM = await this.db.getLastSentCCM(); if (!lastSentCCM) { throw new Error('No CCM was sent so far.'); } @@ -89,10 +86,10 @@ export class Endpoint extends BasePluginEndpoint { }; } - public async getValidatorsInfoFromPreimage( + public async getAllValidatorsData( _context: PluginEndpointContext, - ): Promise { - const validatorsHashPreimage = await this._chainConnectorStore.getValidatorsHashPreimage(); + ): Promise { + const validatorsHashPreimage = await this.db.getAllValidatorsData(); return validatorsHashPreimagetoJSON(validatorsHashPreimage); } @@ -107,13 +104,13 @@ export class Endpoint extends BasePluginEndpoint { const result = `Successfully ${enable ? 'enabled' : 'disabled'} the chain connector plugin.`; if (!enable) { - await this._chainConnectorStore.deletePrivateKey(this._config.encryptedPrivateKey, password); + await this.db.deletePrivateKey(this._encryptedPrivateKey, password); return { result, }; } - await this._chainConnectorStore.setPrivateKey(this._config.encryptedPrivateKey, password); + await this.db.setPrivateKey(this._encryptedPrivateKey, password); return { result, }; diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/inbox_update.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/inbox_update.ts index b01d920e74..8352b4bf3a 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/inbox_update.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/inbox_update.ts @@ -13,15 +13,15 @@ */ import { ccmSchema, codec, tree } from 'lisk-sdk'; -import { CCMsFromEvents, LastSentCCMWithHeight } from './types'; +import { CCMWithHeight, LastSentCCM } from './types'; /** * @see https://github.com/LiskHQ/lips/blob/main/proposals/lip-0053.md#messagewitnesshashes - * + * * Calculates messageWitnessHashes if there are any pending ccms as well as it filters out ccms * based on last sent ccm nonce. * Also, it checks whether a list of ccm can fit into a CCU based on maxCCUSize - * + * * @param sendingChainChannelInfo Channel info of the sendingChain stored on receivingChain * @param ccmsToBeIncluded Filtered list of CCMs that can be included for a given certificate * @param lastSentCCMInfo Last send CCM info which is used to filter out ccms @@ -36,38 +36,33 @@ export const calculateMessageWitnesses = ( inboxSizeOnReceivingChain: number, outboxSizeOnSendingChain: number, lastSentCCM: { - height: number; nonce: bigint; + height: number; }, - ccmsToBeIncluded: CCMsFromEvents[], + ccmsToBeIncluded: CCMWithHeight[], maxCCUSize: number, ): { crossChainMessages: Buffer[]; messageWitnessHashes: Buffer[]; - lastCCMToBeSent: LastSentCCMWithHeight | undefined; + lastCCMToBeSent: LastSentCCM | undefined; } => { const allSerializedCCMs = []; const includedSerializedCCMs = []; let lastCCMWithHeight; let totalSize = 0; // Make an array of ccms with nonce greater than last sent ccm nonce - for (const ccmsFromEvents of ccmsToBeIncluded) { - const { ccms, height } = ccmsFromEvents; - for (const ccm of ccms) { - if (height !== 0 && lastSentCCM.height === height) { - if (ccm.nonce === lastSentCCM.nonce) { - continue; - } - } - if (inboxSizeOnReceivingChain < outboxSizeOnSendingChain) { - const ccmBytes = codec.encode(ccmSchema, ccm); - totalSize += ccmBytes.length; - if (totalSize < maxCCUSize) { - includedSerializedCCMs.push(ccmBytes); - lastCCMWithHeight = { ...ccm, height: ccmsFromEvents.height }; - } - allSerializedCCMs.push(ccmBytes); + for (const ccm of ccmsToBeIncluded) { + if (ccm.height !== 0 && lastSentCCM.height === ccm.height && ccm.nonce === lastSentCCM.nonce) { + continue; + } + if (inboxSizeOnReceivingChain < outboxSizeOnSendingChain) { + const ccmBytes = codec.encode(ccmSchema, ccm); + totalSize += ccmBytes.length; + if (totalSize < maxCCUSize) { + includedSerializedCCMs.push(ccmBytes); + lastCCMWithHeight = { ...ccm, height: ccm.height }; } + allSerializedCCMs.push(ccmBytes); } } @@ -85,7 +80,10 @@ export const calculateMessageWitnesses = ( return { crossChainMessages: includedSerializedCCMs, messageWitnessHashes: [], - lastCCMToBeSent: lastCCMWithHeight, + lastCCMToBeSent: { + ...(lastCCMWithHeight as LastSentCCM), + outboxSize: outboxSizeOnSendingChain, + }, }; } @@ -99,6 +97,9 @@ export const calculateMessageWitnesses = ( return { crossChainMessages: includedSerializedCCMs, messageWitnessHashes, - lastCCMToBeSent: lastCCMWithHeight, + lastCCMToBeSent: { + ...(lastCCMWithHeight as LastSentCCM), + outboxSize: outboxSizeOnSendingChain, + }, }; }; diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/schemas.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/schemas.ts index b3c4418830..1889f5e6bb 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/schemas.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/schemas.ts @@ -104,6 +104,33 @@ export const validatorsDataSchema = { }, certificateThreshold: { dataType: 'uint64', fieldNumber: 2 }, validatorsHash: { dataType: 'bytes', fieldNumber: 3 }, + height: { dataType: 'uint32', fieldNumber: 4 }, + }, +}; + +export const blockHeaderSchemaWithID = { + $id: `${pluginSchemaIDPrefix}/blockHeaderWithID`, + type: 'object', + required: [...chain.blockHeaderSchema.required, 'id'], + properties: { + ...chain.blockHeaderSchema.properties, + id: { + dataType: 'bytes', + fieldNumber: Object.keys(chain.blockHeaderSchema.properties).length + 1, + }, + }, +}; + +export const transactionSchemaWithID = { + $id: `${pluginSchemaIDPrefix}/transactionSchemaWithID`, + type: 'object', + required: [...chain.transactionSchema.required, 'id'], + properties: { + ...chain.transactionSchema.properties, + id: { + dataType: 'bytes', + fieldNumber: Object.keys(chain.transactionSchema.properties).length + 1, + }, }, }; @@ -159,6 +186,17 @@ export const lastSentCCMWithHeight = { }, }; +export const lastSentCCMSchema = { + $id: `${pluginSchemaIDPrefix}/lastSentCCM`, + type: 'object', + required: [...ccmSchema.required, 'height', 'outboxSize'], + properties: { + ...ccmSchema.properties, + height: { dataType: 'uint32', fieldNumber: Object.keys(ccmSchema.properties).length + 1 }, + outboxSize: { dataType: 'uint32', fieldNumber: Object.keys(ccmSchema.properties).length + 2 }, + }, +}; + export const listOfCCUsSchema = { $id: `${pluginSchemaIDPrefix}/listOfCCUs`, type: 'object', @@ -173,6 +211,26 @@ export const listOfCCUsSchema = { }, }; +export const ccmsAtHeightSchema = { + $id: `${pluginSchemaIDPrefix}/ccmsAtHeight`, + type: 'object', + required: ['ccms'], + properties: { + ccms: { + type: 'array', + fieldNumber: 1, + items: { + type: 'object', + required: [...ccmSchema.required, 'height'], + properties: { + ...ccmSchema.properties, + height: { dataType: 'uint32', fieldNumber: Object.keys(ccmSchema.properties).length + 1 }, + }, + }, + }, + }, +}; + export const ccmsFromEventsSchema = { $id: `${pluginSchemaIDPrefix}/ccmsFromEvents`, type: 'object', diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/types.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/types.ts index a8ac15541c..aa9db64bdf 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/types.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/types.ts @@ -23,12 +23,73 @@ import { Proof, ProveResponse, BFTValidator, + Schema, + ActiveValidatorsUpdate, + InboxUpdate, } from 'lisk-sdk'; export interface BlockHeader extends chain.BlockHeaderAttrs { validatorsHash: Buffer; } +export interface Logger { + readonly trace: (data?: Record | unknown, message?: string) => void; + readonly debug: (data?: Record | unknown, message?: string) => void; + readonly info: (data?: Record | unknown, message?: string) => void; + readonly warn: (data?: Record | unknown, message?: string) => void; + readonly error: (data?: Record | unknown, message?: string) => void; + readonly fatal: (data?: Record | unknown, message?: string) => void; + readonly level: () => number; +} + +export interface GenesisConfig { + [key: string]: unknown; + readonly bftBatchSize: number; + readonly chainID: string; + readonly blockTime: number; + readonly maxTransactionsSize: number; +} + +export interface NodeInfo { + readonly version: string; + readonly networkVersion: string; + readonly chainID: string; + readonly lastBlockID: string; + readonly height: number; + readonly genesisHeight: number; + readonly finalizedHeight: number; + readonly syncing: boolean; + readonly unconfirmedTransactions: number; + readonly genesis: GenesisConfig; + readonly network: { + readonly port: number; + readonly hostIp?: string; + readonly seedPeers: { + readonly ip: string; + readonly port: number; + }[]; + readonly blacklistedIPs?: string[]; + readonly fixedPeers?: string[]; + readonly whitelistedPeers?: { + readonly ip: string; + readonly port: number; + }[]; + }; +} + +export type ModuleMetadata = { + stores: { + key: string; + data: Schema; + }[]; + events: { + name: string; + data: Schema; + }[]; + name: string; +}; +export type ModulesMetadata = ModuleMetadata[]; + export interface ChainConnectorPluginConfig { receivingChainID: string; receivingChainWsURL?: string; @@ -49,16 +110,33 @@ export interface ActiveValidatorWithAddress extends ActiveValidator { address: Buffer; } -export interface ValidatorsData { +export interface BFTParametersWithoutGeneratorKey extends Omit { + validators: { + address: Buffer; + bftWeight: bigint; + blsKey: Buffer; + }[]; +} + +export interface ValidatorsDataWithHeight { certificateThreshold: bigint; validators: ActiveValidatorWithAddress[]; validatorsHash: Buffer; + height: number; } export interface LastSentCCMWithHeight extends CCMsg { height: number; } +export interface LastSentCCM extends CCMsg { + height: number; + outboxSize: number; +} + +export interface CCMWithHeight extends CCMsg { + height: number; +} export interface CCMsFromEvents { ccms: CCMsg[]; height: number; @@ -66,6 +144,14 @@ export interface CCMsFromEvents { outboxSize: number; } +export interface CCUpdateParams { + sendingChainID: Buffer; + certificate: Buffer; + activeValidatorsUpdate: ActiveValidatorsUpdate; + certificateThreshold: bigint; + inboxUpdate: InboxUpdate; +} + type Primitive = string | number | bigint | boolean | null | undefined; type Replaced = T extends TReplace | TKeep ? T extends TReplace @@ -79,13 +165,15 @@ export type JSONObject = Replaced; export type CCMsFromEventsJSON = JSONObject; +export type CCMWithHeightJSON = JSONObject; + export type LastSentCCMWithHeightJSON = JSONObject; export type AggregateCommitJSON = JSONObject; export type BFTValidatorJSON = JSONObject; -export type ValidatorsDataJSON = JSONObject; +export type ValidatorsDataHeightJSON = JSONObject; export type ProofJSON = JSONObject; diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/src/utils.ts b/framework-plugins/lisk-framework-chain-connector-plugin/src/utils.ts index 39b17c450c..8ecd4e48ae 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/src/utils.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/src/utils.ts @@ -22,28 +22,22 @@ import { InboxJSON, Outbox, OutboxJSON, - BFTParameters, ProveResponse, } from 'lisk-sdk'; import { BFTParametersJSON, + BFTParametersWithoutGeneratorKey, + CCMWithHeight, + CCMWithHeightJSON, CCMsFromEvents, CCMsFromEventsJSON, ProveResponseJSON, - ValidatorsData, + ValidatorsDataWithHeight, } from './types'; import { CHAIN_ID_LENGTH } from './constants'; -interface BFTParametersWithoutGeneratorKey extends Omit { - validators: { - address: Buffer; - bftWeight: bigint; - blsKey: Buffer; - }[]; -} - export const getMainchainID = (chainID: Buffer): Buffer => { const networkID = chainID.slice(0, 1); // 3 bytes for remaining chainID bytes @@ -79,7 +73,20 @@ export const ccmsFromEventsToJSON = (ccmsFromEvents: CCMsFromEvents): CCMsFromEv outboxSize: ccmsFromEvents.outboxSize, }); -export const validatorsHashPreimagetoJSON = (validatorsHashPreimage: ValidatorsData[]) => { +export const ccmsWithHeightToJSON = (ccmsWithHeight: CCMWithHeight[]): CCMWithHeightJSON[] => + ccmsWithHeight.map(ccm => ({ + ...ccm, + fee: ccm.fee.toString(), + nonce: ccm.nonce.toString(), + params: ccm.params.toString('hex'), + receivingChainID: ccm.receivingChainID.toString('hex'), + sendingChainID: ccm.sendingChainID.toString('hex'), + height: ccm.height, + })); + +export const validatorsHashPreimagetoJSON = ( + validatorsHashPreimage: ValidatorsDataWithHeight[], +) => { const validatorsHashPreimageJSON = []; for (const validatorData of validatorsHashPreimage) { const validatorsJSON = validatorData.validators.map(v => ({ @@ -91,6 +98,7 @@ export const validatorsHashPreimagetoJSON = (validatorsHashPreimage: ValidatorsD certificateThreshold: validatorData.certificateThreshold.toString(), validators: validatorsJSON, validatorsHash: validatorData.validatorsHash.toString('hex'), + height: validatorData.height, }); } return validatorsHashPreimageJSON; diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/block_event_handler.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/block_event_handler.spec.ts new file mode 100644 index 0000000000..141cea6590 --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/block_event_handler.spec.ts @@ -0,0 +1,806 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + ChainAccount, + LastCertificate, + MAX_CCM_SIZE, + MODULE_NAME_INTEROPERABILITY, + ccmSchema, + codec, + cryptography, + testing, +} from 'lisk-sdk'; +import { BlockEventHandler } from '../../src/block_event_handler'; +import { Logger } from '../../src/types'; +import { CCM_SEND_SUCCESS } from '../../src/constants'; +import { getSampleCCM } from '../utils/sampleCCM'; + +describe('BlockEventHandler', () => { + // Constants + const ownChainIDStr = '04000000'; + const ownChainIDBuf = Buffer.from(ownChainIDStr, 'hex'); + const receivingChainIDStr = '04000001'; + const receivingChainIDBuf = Buffer.from(receivingChainIDStr, 'hex'); + const defaultLastSentCCM = { + crossChainCommand: '', + fee: BigInt(1000), + height: 1, + module: 'token', + nonce: BigInt(1), + outboxSize: 1, + params: Buffer.alloc(2), + receivingChainID: receivingChainIDBuf, + sendingChainID: ownChainIDBuf, + status: 0, + }; + + const apiClientMocks = (): any => ({ + connect: jest.fn(), + disconnect: jest.fn(), + subscribe: jest.fn(), + postTransaction: jest.fn(), + getTransactionByID: jest.fn(), + getAuthAccountNonceFromPublicKey: jest.fn(), + getNodeInfo: jest.fn(), + getChannelAccount: jest.fn(), + getChainAccount: jest.fn(), + hasUserTokenAccount: jest.fn(), + getTokenInitializationFee: jest.fn(), + getBFTHeights: jest.fn(), + getEvents: jest.fn(), + getMetadataByModuleName: jest.fn(), + getInclusionProof: jest.fn(), + getSavedInclusionProofAtHeight: jest.fn(), + getBFTParametersAtHeight: jest.fn(), + }); + + // Mocks + let ccuHandlerMock: any; + let receivingChainAPIClientMock: any; + let sendingChainAPIClientMock: any; + let chainConnectorDBMock: any; + + let blockEventHandlerConfig: any; + let initArgs: any; + let blockEventHandler: BlockEventHandler; + + beforeEach(() => { + blockEventHandlerConfig = { + registrationHeight: 10, + ownChainID: ownChainIDBuf, + receivingChainID: receivingChainIDBuf, + maxCCUSize: MAX_CCM_SIZE, + ccuFee: '100000', + isSaveCCU: false, + ccuSaveLimit: 300, + }; + + receivingChainAPIClientMock = apiClientMocks(); + sendingChainAPIClientMock = apiClientMocks(); + + chainConnectorDBMock = { + getListOfCCUs: jest.fn(), + saveToDBOnNewBlock: jest.fn(), + getBlockHeaderByHeight: jest.fn(), + deleteBlockHeadersBetweenHeight: jest.fn(), + deleteBlockHeaderByHeight: jest.fn(), + getAggregateCommitByHeight: jest.fn(), + deleteAggregateCommitsBetweenHeight: jest.fn(), + deleteAggregateCommitByHeight: jest.fn(), + getValidatorsDataByHash: jest.fn(), + setValidatorsDataByHash: jest.fn(), + getValidatorsDataByHeight: jest.fn(), + deleteValidatorsHashByHeight: jest.fn(), + deleteValidatorsHashBetweenHeights: jest.fn(), + deleteValidatorsDataByHash: jest.fn(), + getCCMsByHeight: jest.fn(), + getCCMsBetweenHeights: jest.fn(), + deleteCCMsBetweenHeight: jest.fn(), + deleteCCMsByHeight: jest.fn(), + setCCMsByHeight: jest.fn(), + getLastSentCCM: jest.fn(), + setLastSentCCM: jest.fn(), + setCCUTransaction: jest.fn(), + deleteCCUTransaction: jest.fn(), + setPrivateKey: jest.fn(), + deletePrivateKey: jest.fn(), + }; + + initArgs = { + logger: testing.mocks.loggerMock, + db: chainConnectorDBMock, + sendingChainAPIClient: sendingChainAPIClientMock, + receivingChainAPIClient: receivingChainAPIClientMock, + }; + + ccuHandlerMock = { + load: jest.fn(), + computeCCU: jest.fn(), + submitCCU: jest.fn(), + }; + + blockEventHandler = new BlockEventHandler(blockEventHandlerConfig); + + (blockEventHandler as any)['_db'] = chainConnectorDBMock; + (blockEventHandler as any)['_ccuHandler'] = ccuHandlerMock; + (blockEventHandler as any)['_sendingChainAPIClient'] = sendingChainAPIClientMock; + (blockEventHandler as any)['_receivingChainAPIClient'] = receivingChainAPIClientMock; + jest + .spyOn(blockEventHandler['_sendingChainAPIClient'], 'getMetadataByModuleName') + .mockResolvedValue({ + stores: [], + events: [], + name: 'interoperability', + }); + jest.spyOn(blockEventHandler['_ccuHandler'], 'load').mockReturnValue(); + jest.spyOn(blockEventHandler['_receivingChainAPIClient'], 'connect').mockResolvedValue(); + jest.spyOn(blockEventHandler['_db'], 'getLastSentCCM').mockResolvedValue(defaultLastSentCCM); + jest.spyOn(blockEventHandler['_sendingChainAPIClient'], 'subscribe').mockReturnValue(); + }); + + describe('load', () => { + beforeEach(async () => { + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + await blockEventHandler.load(initArgs); + }); + + it('Should call getMetadataByModuleName on _sendingChainAPIClient', () => { + expect( + blockEventHandler['_sendingChainAPIClient'].getMetadataByModuleName, + ).toHaveBeenCalledTimes(1); + }); + + it('Should call ccu handler load function', () => { + expect(blockEventHandler['_ccuHandler'].load).toHaveBeenCalledTimes(1); + }); + + it('Should call connect on _receivingChainAPIClient', () => { + expect(blockEventHandler['_receivingChainAPIClient'].connect).toHaveBeenCalledTimes(2); + }); + + it('Should call getLastSentCCM on db', () => { + expect(blockEventHandler['_db'].getLastSentCCM).toHaveBeenCalledTimes(1); + }); + + it('Should call connect on receivingChainAPIClient', () => { + expect(blockEventHandler['_receivingChainAPIClient'].connect).toHaveBeenCalledTimes(2); + }); + + it('Should subscribe to chain_newBlock event on _sendingChainAPIClient', () => { + expect(blockEventHandler['_sendingChainAPIClient'].subscribe).toHaveBeenCalledWith( + 'chain_newBlock', + expect.anything(), + ); + }); + + it('Should subscribe to newchain_deleteBlockBlock event on _sendingChainAPIClient', () => { + expect(blockEventHandler['_sendingChainAPIClient'].subscribe).toHaveBeenCalledWith( + 'chain_deleteBlock', + expect.anything(), + ); + }); + + it('Should call _initializeReceivingChainClient', () => { + expect(blockEventHandler['_initializeReceivingChainClient']).toHaveBeenCalledTimes(1); + }); + }); + + describe('_handleNewBlock', () => { + const saveOnNewBlockMock = jest.fn(); + const sidechainLastCertificate: LastCertificate = { + height: 1, + stateRoot: cryptography.utils.hash(Buffer.alloc(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + }; + const sidechainChainAccount: ChainAccount = { + lastCertificate: sidechainLastCertificate, + name: 'sidechain1', + status: 1, + }; + + let sampleBlockHeader: Record; + + beforeEach(async () => { + blockEventHandler['_saveOnNewBlock'] = saveOnNewBlockMock; + sampleBlockHeader = testing.createFakeBlockHeader({ height: 100 }).toJSON(); + sampleBlockHeader.generatorAddress = 'lskoaknq582o6fw7sp82bm2hnj7pzp47mpmbmux2g'; + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + await blockEventHandler.load(initArgs); + }); + + afterAll(() => { + clearTimeout(blockEventHandler['_sentCCUTxTimeout']); + }); + + it('should throw when _saveOnNewBlock fails', async () => { + const fakeError = new Error('Error occurred while save on new block'); + saveOnNewBlockMock.mockRejectedValue(fakeError); + jest.spyOn(initArgs.logger as Logger, 'error'); + + await expect( + blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }), + ).rejects.toThrow(fakeError); + }); + + it('should return after getNodeInfo when the node is syncing', async () => { + saveOnNewBlockMock.mockResolvedValue({}); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: true }); + jest.spyOn(initArgs.logger as Logger, 'debug'); + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + + expect((initArgs.logger as Logger).debug).toHaveBeenCalledWith( + 'No CCU generation is possible as the node is syncing.', + ); + expect(receivingChainAPIClientMock.getChainAccount).not.toHaveBeenCalled(); + }); + + it('should log error and call _initializeReceivingChainClient when getChainAccount call fails', async () => { + const fakeError = new Error('Error occurred while accessing _receivingChainAPIClient'); + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockRejectedValue(fakeError); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'error'); + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + + expect((initArgs.logger as Logger).error).toHaveBeenCalledWith( + { err: fakeError }, + 'Error occurred while accessing receivingChainAPIClient but all data is saved on new block.', + ); + expect(blockEventHandler['_initializeReceivingChainClient']).toHaveBeenCalled(); + }); + + it('should log message and return when chainAccount does not exists', async () => { + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(undefined); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + 'Sending chain is not registered to the receiving chain yet and has no chain data.', + ); + }); + + it('should log message and return when ccuFrequency is not reached', async () => { + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + (blockEventHandler as any)['_ccuFrequency'] = 100; + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + `Last certificate value has been set with height ${sidechainLastCertificate.height}`, + ); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + { + ccuFrequency: 100, + nextPossibleCCUHeight: + 100 - ((sampleBlockHeader as any).height - sidechainLastCertificate.height), + }, + 'No attempt to create CCU either due to provided ccuFrequency', + ); + }); + + it('should throw error when getChainAccount fails on sending chain client', async () => { + const fakeError = new Error('Failed to get chain account'); + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + sendingChainAPIClientMock.getChainAccount.mockRejectedValue(fakeError); + jest.spyOn(initArgs.logger as Logger, 'error'); + + await expect( + blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }), + ).rejects.toThrow(fakeError); + }); + + it('should log message and return when receiving chain is not registered yet', async () => { + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getChainAccount.mockResolvedValue(undefined); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + blockEventHandler['_isReceivingChainRegistered'] = false; + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + 'Receiving chain is not registered on the sending chain yet and has no chain data.', + ); + }); + + it('should log message and return when last CCU sent was not included on the receiving chain', async () => { + const lastSentCCUTxID = 'txid'; + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + blockEventHandler['_isReceivingChainRegistered'] = true; + blockEventHandler['_lastSentCCUTxID'] = lastSentCCUTxID; + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + `Still pending CCU on the receiving CCU with tx ID ${lastSentCCUTxID}`, + ); + }); + + it('should log error and return when computeCCU fails', async () => { + const lastSentCCUTxID = ''; + const fakeError = new Error('Failed at computeCCU'); + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'error'); + jest.spyOn(blockEventHandler['_ccuHandler'], 'computeCCU').mockRejectedValue(fakeError); + blockEventHandler['_isReceivingChainRegistered'] = true; + blockEventHandler['_lastSentCCUTxID'] = lastSentCCUTxID; + + await expect( + blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }), + ).rejects.toThrow(fakeError); + }); + + it('should log error when submitCCU fails', async () => { + const lastSentCCUTxID = ''; + const fakeError = new Error('Failed at computeCCU'); + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'error'); + jest.spyOn(blockEventHandler['_ccuHandler'], 'computeCCU').mockResolvedValue({ + ccuParams: {} as any, + lastCCMToBeSent: {} as any, + }); + jest.spyOn(blockEventHandler['_ccuHandler'], 'submitCCU').mockRejectedValue(fakeError); + + blockEventHandler['_isReceivingChainRegistered'] = true; + blockEventHandler['_lastSentCCUTxID'] = lastSentCCUTxID; + + await expect( + blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }), + ).rejects.toThrow(fakeError); + }); + + it('should log message when submitCCU returns undefined', async () => { + const lastSentCCUTxID = ''; + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest.spyOn(blockEventHandler['_ccuHandler'], 'computeCCU').mockResolvedValue({ + ccuParams: {} as any, + lastCCMToBeSent: {} as any, + }); + jest.spyOn(blockEventHandler['_ccuHandler'], 'submitCCU').mockResolvedValue(undefined); + + blockEventHandler['_isReceivingChainRegistered'] = true; + blockEventHandler['_lastSentCCUTxID'] = lastSentCCUTxID; + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + `Last sent CCU tx with ID ${lastSentCCUTxID} was not yet included in the receiving chain.`, + ); + }); + + it('should set _lastSentCCM and _lastSentCCUTxID when submitCCU is successful', async () => { + const lastSentCCUTxID = ''; + saveOnNewBlockMock.mockResolvedValue({}); + receivingChainAPIClientMock.getChainAccount.mockResolvedValue(sidechainChainAccount); + sendingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest.spyOn(blockEventHandler['_ccuHandler'], 'computeCCU').mockResolvedValue({ + ccuParams: {} as any, + lastCCMToBeSent: { + nonce: BigInt(1), + } as any, + }); + jest.spyOn(blockEventHandler['_ccuHandler'], 'submitCCU').mockResolvedValue('txID'); + + blockEventHandler['_isReceivingChainRegistered'] = true; + blockEventHandler['_lastSentCCUTxID'] = lastSentCCUTxID; + + await blockEventHandler['_handleNewBlock']({ blockHeader: sampleBlockHeader }); + expect(blockEventHandler['_lastSentCCUTxID']).toBe('txID'); + expect(blockEventHandler['_lastSentCCM']).toEqual({ + nonce: BigInt(1), + }); + }); + }); + + describe('_saveOnNewBlock', () => { + const ccmSendSuccessDataSchema = { + $id: '/interoperability/events/ccmSendSuccess', + type: 'object', + required: ['ccm'], + properties: { + ccm: { + fieldNumber: 1, + type: ccmSchema.type, + required: [...ccmSchema.required], + properties: { + ...ccmSchema.properties, + }, + }, + }, + }; + const sampleValidatorsData = { + validators: [], + precommitThreshold: BigInt(1), + prevoteThreshold: BigInt(1), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + certificateThreshold: BigInt(1), + }; + + let sampleBlockHeader: any; + + beforeEach(async () => { + jest.spyOn(blockEventHandler['_db'], 'saveToDBOnNewBlock'); + jest.spyOn(blockEventHandler['_db'], 'setCCMsByHeight'); + jest + .spyOn(blockEventHandler['_sendingChainAPIClient'], 'getBFTParametersAtHeight') + .mockResolvedValue(sampleValidatorsData); + jest.spyOn(blockEventHandler['_db'], 'setValidatorsDataByHash'); + sampleBlockHeader = testing.createFakeBlockHeader({ height: 100 }).toObject(); + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + + await blockEventHandler.load(initArgs); + blockEventHandler['_interoperabilityMetadata'] = { + stores: [ + { + key: '1', + data: ccmSchema, + }, + ], + events: [ + { + name: CCM_SEND_SUCCESS, + data: ccmSendSuccessDataSchema, + }, + ], + name: MODULE_NAME_INTEROPERABILITY, + }; + }); + + it('should set empty CCMs by height and validators data at the block height', async () => { + jest.spyOn(blockEventHandler['_sendingChainAPIClient'], 'getEvents').mockResolvedValue([]); + await blockEventHandler['_saveOnNewBlock'](sampleBlockHeader); + expect(blockEventHandler['_db'].saveToDBOnNewBlock).toHaveBeenCalledWith(sampleBlockHeader); + expect(blockEventHandler['_db'].setCCMsByHeight).toHaveBeenCalledWith( + [], + sampleBlockHeader.height as number, + ); + expect(blockEventHandler['_sendingChainAPIClient'].getEvents).toHaveBeenCalledWith( + sampleBlockHeader.height as number, + ); + expect( + blockEventHandler['_sendingChainAPIClient'].getBFTParametersAtHeight, + ).toHaveBeenCalledWith(sampleBlockHeader.height as number); + expect(blockEventHandler['_db'].setValidatorsDataByHash).toHaveBeenCalledWith( + sampleValidatorsData.validatorsHash, + { ...sampleValidatorsData, height: sampleBlockHeader.height }, + sampleBlockHeader.height as number, + ); + }); + + it('should set CCMs from events by height and validators data at the block height', async () => { + const ccm = getSampleCCM(); + + jest.spyOn(blockEventHandler['_sendingChainAPIClient'], 'getEvents').mockResolvedValue([ + { + data: codec.encode(ccmSendSuccessDataSchema, { ccm }).toString('hex'), + height: sampleBlockHeader.height, + index: 1, + module: MODULE_NAME_INTEROPERABILITY, + name: CCM_SEND_SUCCESS, + topics: [], + }, + ]); + + (blockEventHandler as any)['_isReceivingChainMainchain'] = true; + await blockEventHandler['_saveOnNewBlock'](sampleBlockHeader); + + expect(blockEventHandler['_db'].saveToDBOnNewBlock).toHaveBeenCalledWith(sampleBlockHeader); + expect(blockEventHandler['_db'].setCCMsByHeight).toHaveBeenCalledWith( + [{ ...ccm, height: sampleBlockHeader.height as number }], + sampleBlockHeader.height as number, + ); + expect(blockEventHandler['_sendingChainAPIClient'].getEvents).toHaveBeenCalledWith( + sampleBlockHeader.height as number, + ); + expect( + blockEventHandler['_sendingChainAPIClient'].getBFTParametersAtHeight, + ).toHaveBeenCalledWith(sampleBlockHeader.height as number); + expect(blockEventHandler['_db'].setValidatorsDataByHash).toHaveBeenCalledWith( + sampleValidatorsData.validatorsHash, + { ...sampleValidatorsData, height: sampleBlockHeader.height }, + sampleBlockHeader.height as number, + ); + }); + }); + + describe('_initializeReceivingChainClient', () => { + beforeEach(async () => { + await blockEventHandler.load(initArgs); + }); + + it('should throw error if connect fails on _receivingChainAPIClient', async () => { + const fakeError = new Error('Unable to connect'); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'connect') + .mockRejectedValue(fakeError); + jest.spyOn(initArgs.logger as Logger, 'error'); + + await blockEventHandler['_initializeReceivingChainClient'](); + + expect(blockEventHandler['_receivingChainAPIClient'].connect).toHaveBeenCalled(); + expect((initArgs.logger as Logger).error).toHaveBeenCalledWith( + fakeError, + 'Not able to connect to receivingChainAPIClient. Trying again on next new block.', + ); + }); + + it('should call connect on _receivingChainAPIClient and subscribe', async () => { + jest.spyOn(blockEventHandler['_receivingChainAPIClient'], 'connect').mockResolvedValue(); + jest.spyOn(blockEventHandler['_receivingChainAPIClient'], 'subscribe'); + + await blockEventHandler['_initializeReceivingChainClient'](); + + expect(blockEventHandler['_receivingChainAPIClient'].connect).toHaveBeenCalled(); + expect(blockEventHandler['_receivingChainAPIClient'].subscribe).toHaveBeenCalled(); + }); + }); + + describe('_newBlockReceivingChainHandler', () => { + const sidechainLastCertificate: LastCertificate = { + height: 1, + stateRoot: cryptography.utils.hash(Buffer.alloc(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + }; + const sidechainChainAccount: ChainAccount = { + lastCertificate: sidechainLastCertificate, + name: 'sidechain1', + status: 1, + }; + + beforeEach(async () => { + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + await blockEventHandler.load(initArgs); + }); + + it('Should return if the receiving chain is syncing', async () => { + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getNodeInfo') + .mockResolvedValue({ syncing: true, finalizedHeight: 1 } as any); + jest.spyOn(initArgs.logger as Logger, 'debug'); + await blockEventHandler['_newBlockReceivingChainHandler'](); + + expect((initArgs.logger as Logger).debug).toHaveBeenCalledWith('Receiving chain is syncing.'); + }); + + it('Should throw error if no channel data available on receiving chain', async () => { + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getNodeInfo') + .mockResolvedValue({ syncing: false, finalizedHeight: 1 } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue(undefined); + jest.spyOn(initArgs.logger as Logger, 'debug'); + + await blockEventHandler['_newBlockReceivingChainHandler'](); + + expect((initArgs.logger as Logger).debug).toHaveBeenCalledWith( + new Error('No channel data available on receiving chain.'), + 'Error occured while receiving block from receiving chain.', + ); + }); + + it('Should throw error if no chain data available on receiving chain', async () => { + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getNodeInfo') + .mockResolvedValue({ syncing: false, finalizedHeight: 1 } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChainAccount') + .mockResolvedValue(undefined); + jest.spyOn(initArgs.logger as Logger, 'debug'); + + await blockEventHandler['_newBlockReceivingChainHandler'](); + + expect((initArgs.logger as Logger).debug).toHaveBeenCalledWith( + new Error('No chain data available on receiving chain.'), + 'Error occured while receiving block from receiving chain.', + ); + }); + + it('Should throw if failed to get transaction by id on receiving chain', async () => { + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getNodeInfo') + .mockResolvedValue({ syncing: false, finalizedHeight: 1 } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChainAccount') + .mockResolvedValue(sidechainChainAccount); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getTransactionByID') + .mockRejectedValue('Failed to get transaction by ID'); + jest.spyOn(initArgs.logger as Logger, 'debug'); + blockEventHandler['_lastSentCCUTxID'] = 'txID'; + await blockEventHandler['_newBlockReceivingChainHandler'](); + + expect((initArgs.logger as Logger).debug).toHaveBeenCalledWith( + new Error(`Failed to get transaction with ID ${blockEventHandler['_lastSentCCUTxID']}`), + 'Error occured while receiving block from receiving chain.', + ); + }); + + it('Should set _lastSentCCM, _lastSentCCUTxID and call cleanup if last tx was included on receiving chain', async () => { + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getNodeInfo') + .mockResolvedValue({ syncing: false, finalizedHeight: 1 } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getChainAccount') + .mockResolvedValue(sidechainChainAccount); + jest + .spyOn(blockEventHandler['_receivingChainAPIClient'], 'getTransactionByID') + .mockResolvedValue({} as never); + jest.spyOn(blockEventHandler as any, '_cleanup'); + + blockEventHandler['_lastSentCCUTxID'] = 'txID'; + await blockEventHandler['_newBlockReceivingChainHandler'](); + + expect(blockEventHandler['_cleanup']).toHaveBeenCalled(); + }); + }); + + describe('_cleanup', () => { + beforeEach(async () => { + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + jest.spyOn(blockEventHandler['_db'], 'getListOfCCUs').mockResolvedValue({ + list: [ + { + id: '133', + }, + { + id: '123', + }, + ], + total: 2, + }); + jest.spyOn(blockEventHandler['_db'], 'deleteCCUTransaction'); + jest.spyOn(blockEventHandler['_db'], 'deleteCCMsBetweenHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteBlockHeadersBetweenHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteAggregateCommitsBetweenHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteValidatorsHashBetweenHeights'); + jest.spyOn(initArgs.logger as Logger, 'debug'); + await blockEventHandler.load(initArgs); + }); + + it('Should delete CCUs if ccuSaveLimit is not equal to -1', async () => { + (blockEventHandler as any)['_ccuSaveLimit'] = 1; + (blockEventHandler as any)['_lastCertificate'] = { + height: 1, + stateRoot: cryptography.utils.hash(Buffer.alloc(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + }; + await blockEventHandler['_cleanup'](); + expect(blockEventHandler['_db'].deleteCCUTransaction).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteCCMsBetweenHeight).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteBlockHeadersBetweenHeight).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteAggregateCommitsBetweenHeight).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteValidatorsHashBetweenHeights).toHaveBeenCalled(); + expect((initArgs.logger as Logger).debug).toHaveBeenCalled(); + }); + + it('Should delete if there is a info at finalized height', async () => { + (blockEventHandler as any)['_ccuSaveLimit'] = 1; + blockEventHandler['_receivingChainFinalizedHeight'] = 2; + const lastDeletionHeight = 1; + blockEventHandler['_lastDeletionHeight'] = lastDeletionHeight; + const finalizedInfoAtHeight = { lastCertificateHeight: 5, inboxSize: 1 }; + blockEventHandler['_heightToDeleteIndex'].set( + blockEventHandler['_receivingChainFinalizedHeight'], + finalizedInfoAtHeight, + ); + (blockEventHandler as any)['_lastCertificate'] = { + height: 1, + stateRoot: cryptography.utils.hash(Buffer.alloc(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + }; + await blockEventHandler['_cleanup'](); + expect(blockEventHandler['_db'].deleteCCUTransaction).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteCCMsBetweenHeight).toHaveBeenCalledWith( + lastDeletionHeight, + finalizedInfoAtHeight.lastCertificateHeight - 1, + ); + expect(blockEventHandler['_db'].deleteBlockHeadersBetweenHeight).toHaveBeenCalledWith( + lastDeletionHeight, + finalizedInfoAtHeight.lastCertificateHeight - 1, + ); + expect(blockEventHandler['_db'].deleteAggregateCommitsBetweenHeight).toHaveBeenCalledWith( + lastDeletionHeight, + finalizedInfoAtHeight.lastCertificateHeight - 1, + ); + expect(blockEventHandler['_db'].deleteValidatorsHashBetweenHeights).toHaveBeenCalledWith( + lastDeletionHeight, + finalizedInfoAtHeight.lastCertificateHeight - 1, + ); + expect((initArgs.logger as Logger).debug).toHaveBeenCalled(); + }); + + it('Should not delete if last certificate height is zero', async () => { + (blockEventHandler as any)['_ccuSaveLimit'] = 1; + (blockEventHandler as any)['_lastCertificate'] = { + height: 0, + stateRoot: cryptography.utils.hash(Buffer.alloc(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(2)), + }; + await blockEventHandler['_cleanup'](); + expect(blockEventHandler['_db'].deleteCCUTransaction).toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteCCMsBetweenHeight).not.toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteBlockHeadersBetweenHeight).not.toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteAggregateCommitsBetweenHeight).not.toHaveBeenCalled(); + expect(blockEventHandler['_db'].deleteValidatorsHashBetweenHeights).not.toHaveBeenCalled(); + expect((initArgs.logger as Logger).debug).not.toHaveBeenCalled(); + }); + }); + + describe('_deleteBlockHandler', () => { + let sampleBlockHeader: Record; + + beforeEach(async () => { + sampleBlockHeader = testing.createFakeBlockHeader({ height: 100 }).toJSON(); + sampleBlockHeader.generatorAddress = 'lskoaknq582o6fw7sp82bm2hnj7pzp47mpmbmux2g'; + jest.spyOn(blockEventHandler as any, '_initializeReceivingChainClient'); + jest.spyOn(blockEventHandler['_db'], 'deleteCCMsByHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteBlockHeaderByHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteAggregateCommitByHeight'); + jest.spyOn(blockEventHandler['_db'], 'deleteValidatorsHashByHeight'); + + await blockEventHandler.load(initArgs); + }); + + it('Should delete all the data associated with the deleted block', async () => { + await blockEventHandler['_deleteBlockHandler']({ blockHeader: sampleBlockHeader }); + + expect(blockEventHandler['_db'].deleteCCMsByHeight).toHaveBeenCalledWith( + sampleBlockHeader.height, + ); + expect(blockEventHandler['_db'].deleteBlockHeaderByHeight).toHaveBeenCalledWith( + sampleBlockHeader.height, + ); + expect(blockEventHandler['_db'].deleteAggregateCommitByHeight).toHaveBeenCalledWith( + sampleBlockHeader.height, + ); + expect(blockEventHandler['_db'].deleteValidatorsHashByHeight).toHaveBeenCalledWith( + sampleBlockHeader.height, + ); + }); + }); +}); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/ccu_handler.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/ccu_handler.spec.ts new file mode 100644 index 0000000000..1fd65d4d73 --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/ccu_handler.spec.ts @@ -0,0 +1,1037 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + ActiveValidatorsUpdate, + Certificate, + CrossChainUpdateTransactionParams, + EMPTY_BYTES, + LastCertificate, + MODULE_NAME_INTEROPERABILITY, + Transaction, + ccmSchema, + ccuParamsSchema, + certificateSchema, + codec, + cryptography, + testing, + transactions, +} from 'lisk-sdk'; +import { when } from 'jest-when'; +import { CCM_SEND_SUCCESS, COMMAND_NAME_SUBMIT_MAINCHAIN_CCU } from '../../src/constants'; +import { CCUHandler } from '../../src/ccu_handler'; +import { LastSentCCM, Logger, ValidatorsDataWithHeight } from '../../src/types'; +import { getSampleCCM } from '../utils/sampleCCM'; +import * as inboxUtility from '../../src/inbox_update'; +import * as certificateUtility from '../../src/certificate_generation'; +import { calculateActiveValidatorsUpdate } from '../../src/active_validators_update'; +import { getCertificateFromAggregateCommitByBlockHeader } from '../../src/certificate_generation'; + +describe('CCUHandler', () => { + const apiClientMocks = (): any => ({ + connect: jest.fn(), + disconnect: jest.fn(), + subscribe: jest.fn(), + postTransaction: jest.fn(), + getTransactionByID: jest.fn(), + getAuthAccountNonceFromPublicKey: jest.fn(), + getNodeInfo: jest.fn(), + getChannelAccount: jest.fn(), + getChainAccount: jest.fn(), + hasUserTokenAccount: jest.fn(), + getTokenInitializationFee: jest.fn(), + getBFTHeights: jest.fn(), + getEvents: jest.fn(), + getMetadataByModuleName: jest.fn(), + getInclusionProof: jest.fn(), + getSavedInclusionProofAtHeight: jest.fn(), + getBFTParametersAtHeight: jest.fn(), + }); + + const ccmSendSuccessDataSchema = { + $id: '/interoperability/events/ccmSendSuccess', + type: 'object', + required: ['ccm'], + properties: { + ccm: { + fieldNumber: 1, + type: ccmSchema.type, + required: [...ccmSchema.required], + properties: { + ...ccmSchema.properties, + }, + }, + }, + }; + + let ccuHandler: CCUHandler; + let config: any; + let initArgs: any; + let chainConnectorDBMock: any; + let receivingChainAPIClientMock: any; + let sendingChainAPIClientMock: any; + let sampleLastCertificate: LastCertificate; + let sampleValidatorsDataAtLastCertificate: ValidatorsDataWithHeight; + + beforeEach(() => { + config = { + registrationHeight: 10, + ownChainID: Buffer.from('04000000', 'hex'), + receivingChainID: Buffer.from('04000001', 'hex'), + maxCCUSize: 108000, + ccuFee: '10000000', + isSaveCCU: false, + }; + sampleLastCertificate = { + height: 12, + stateRoot: cryptography.utils.hash(cryptography.utils.getRandomBytes(2)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(cryptography.utils.getRandomBytes(2)), + }; + sampleValidatorsDataAtLastCertificate = { + validators: [ + { + address: cryptography.utils.getRandomBytes(32), + bftWeight: BigInt(1), + blsKey: cryptography.utils.getRandomBytes(54), + }, + ], + validatorsHash: sampleLastCertificate.validatorsHash, + certificateThreshold: BigInt(1), + height: 20, + }; + + receivingChainAPIClientMock = apiClientMocks(); + sendingChainAPIClientMock = apiClientMocks(); + + chainConnectorDBMock = { + getListOfCCUs: jest.fn(), + saveToDBOnNewBlock: jest.fn(), + getBlockHeaderByHeight: jest.fn(), + deleteBlockHeadersBetweenHeight: jest.fn(), + deleteBlockHeaderByHeight: jest.fn(), + getAggregateCommitByHeight: jest.fn(), + getAggregateCommitBetweenHeights: jest.fn(), + deleteAggregateCommitsBetweenHeight: jest.fn(), + deleteAggregateCommitByHeight: jest.fn(), + getValidatorsDataByHash: jest.fn(), + setValidatorsDataByHash: jest.fn(), + getValidatorsDataByHeight: jest.fn(), + deleteValidatorsHashByHeight: jest.fn(), + deleteValidatorsHashBetweenHeights: jest.fn(), + deleteValidatorsDataByHash: jest.fn(), + getCCMsByHeight: jest.fn(), + getCCMsBetweenHeights: jest.fn(), + deleteCCMsBetweenHeight: jest.fn(), + deleteCCMsByHeight: jest.fn(), + setCCMsByHeight: jest.fn(), + getLastSentCCM: jest.fn(), + setLastSentCCM: jest.fn(), + setCCUTransaction: jest.fn(), + deleteCCUTransaction: jest.fn(), + setPrivateKey: jest.fn(), + deletePrivateKey: jest.fn(), + privateKey: undefined, + }; + + sampleLastCertificate = { + height: 21, + stateRoot: cryptography.utils.hash(Buffer.alloc(3)), + timestamp: Math.floor(Date.now() / 1000), + validatorsHash: cryptography.utils.hash(Buffer.alloc(3)), + }; + + initArgs = { + logger: testing.mocks.loggerMock, + db: chainConnectorDBMock, + sendingChainAPIClient: sendingChainAPIClientMock, + receivingChainAPIClient: receivingChainAPIClientMock, + lastCertificate: sampleLastCertificate, + interoperabilityMetadata: { + stores: [ + { + key: '83ed0d250000', + data: { + $id: '/modules/interoperability/outbox', + }, + }, + ], + events: [ + { + name: CCM_SEND_SUCCESS, + data: ccmSendSuccessDataSchema, + }, + ], + name: MODULE_NAME_INTEROPERABILITY, + }, + }; + + ccuHandler = new CCUHandler(config); + }); + + describe('load', () => { + beforeEach(() => { + ccuHandler.load(initArgs); + }); + + it('Should set all the properties and calculate _outboxKeyForInclusionProof', () => { + const expectedOutboxKey = Buffer.concat([ + Buffer.from(initArgs.interoperabilityMetadata.stores[0].key as string, 'hex'), + cryptography.utils.hash(config.receivingChainID), + ]); + + expect(ccuHandler['_outboxKeyForInclusionProof']).toEqual(expectedOutboxKey); + }); + }); + + describe('computeCCU', () => { + let sampleLastSentCCM: LastSentCCM; + + beforeEach(() => { + sampleLastSentCCM = { + height: 12, + outboxSize: 2, + ...getSampleCCM(), + }; + ccuHandler.load(initArgs); + }); + + it('should return if no certificate was found and last certificate height is 0', async () => { + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(undefined); + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 0, + }; + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + expect(result).toBeUndefined(); + }); + + it('should return undefined and log when no pending CCMs and no new certificate', async () => { + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(undefined); + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + + expect(result).toBeUndefined(); + expect((initArgs.logger as Logger).info).toHaveBeenCalledWith( + 'CCU cant be created as there are no pending CCMs for the last certificate.', + ); + }); + + it('should return valid CCU params when there are pending CCMs with old certificate', async () => { + const crossChainMessages = [Buffer.alloc(2)]; + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const outboxRootWitness = { bitmap: Buffer.alloc(0), siblingHashes: [] }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(undefined); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + + expect(result).toEqual({ + ccuParams: { + sendingChainID: config.ownChainID, + activeValidatorsUpdate: { + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: Buffer.alloc(0), + blsKeysUpdate: [], + } as ActiveValidatorsUpdate, + certificate: EMPTY_BYTES, + certificateThreshold: sampleValidatorsDataAtLastCertificate.certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness, + }, + }, + lastCCMToBeSent, + }); + }); + + it('should throw error when no validators data was found for validators hash from last certificate', async () => { + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleLastCertificate.validatorsHash, + }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockResolvedValue({ + proof: { + queries: [ + { + bitmap: Buffer.alloc(1), + key: ccuHandler['_outboxKeyForInclusionProof'], + value: Buffer.alloc(2), + }, + ], + siblingHashes: [], + }, + }); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(undefined); + + await expect(ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM)).rejects.toThrow( + `No validators data at last certificate with hash at ${sampleLastCertificate.validatorsHash.toString( + 'hex', + )}`, + ); + }); + + it('should throw error when no validators data was found for validators hash from new certificate', async () => { + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockResolvedValue({ + proof: { + queries: [ + { + bitmap: Buffer.alloc(1), + key: ccuHandler['_outboxKeyForInclusionProof'], + value: Buffer.alloc(2), + }, + ], + siblingHashes: [], + }, + }); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(newCertificate.validatorsHash) + .mockResolvedValue(undefined); + + await expect(ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM)).rejects.toThrow( + `No validators data at new certificate with hash at ${newCertificate.validatorsHash.toString( + 'hex', + )}`, + ); + }); + + it('should throw error when no inclusion proof was found for the new certificate height', async () => { + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleLastCertificate.validatorsHash, + }; + const fakeError = new Error('No inclusion proof for the given height'); + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockRejectedValue(fakeError); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + await expect(ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM)).rejects.toThrow( + fakeError, + ); + }); + + it('should return valid CCU params for the new certificate with no CCMs', async () => { + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleLastCertificate.validatorsHash, + }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockResolvedValue({ + proof: { + queries: [ + { + bitmap: Buffer.alloc(1), + key: ccuHandler['_outboxKeyForInclusionProof'], + value: Buffer.alloc(2), + }, + ], + siblingHashes: [], + }, + }); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + + expect(result).toEqual({ + ccuParams: { + sendingChainID: config.ownChainID, + activeValidatorsUpdate: { + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: EMPTY_BYTES, + blsKeysUpdate: [], + } as ActiveValidatorsUpdate, + certificate: codec.encode(certificateSchema, newCertificate), + certificateThreshold: sampleValidatorsDataAtLastCertificate.certificateThreshold, + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes, + outboxRootWitness: { + bitmap: EMPTY_BYTES, + siblingHashes: [], + }, + }, + }, + lastCCMToBeSent, + }); + }); + + it('should return valid CCU params for the new certificate with CCMs', async () => { + const crossChainMessages = [Buffer.alloc(2)]; + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleLastCertificate.validatorsHash, + }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockResolvedValue({ + proof: { + queries: [ + { + bitmap: Buffer.alloc(1), + key: ccuHandler['_outboxKeyForInclusionProof'], + value: Buffer.alloc(2), + }, + ], + siblingHashes: [], + }, + }); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + + expect(result).toEqual({ + ccuParams: { + sendingChainID: config.ownChainID, + activeValidatorsUpdate: { + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: EMPTY_BYTES, + blsKeysUpdate: [], + } as ActiveValidatorsUpdate, + certificate: codec.encode(certificateSchema, newCertificate), + certificateThreshold: sampleValidatorsDataAtLastCertificate.certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + }, + lastCCMToBeSent, + }); + }); + + it('should return valid CCU params for the new certificate with activeValidatorsUpdate', async () => { + const crossChainMessages = [Buffer.alloc(2)]; + const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + }; + const validatorsDataAtNewCertificate: ValidatorsDataWithHeight = { + validators: [ + { + address: cryptography.utils.getRandomBytes(32), + bftWeight: BigInt(2), + blsKey: cryptography.utils.getRandomBytes(54), + }, + ], + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + certificateThreshold: BigInt(1), + height: 20, + }; + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 2, + }; + + jest.spyOn(ccuHandler as any, '_findCertificate').mockResolvedValue(newCertificate); + jest.spyOn(ccuHandler['_db'], 'getValidatorsDataByHash'); + jest.spyOn(ccuHandler['_db'], 'getCCMsBetweenHeights').mockResolvedValue([]); + jest + .spyOn(ccuHandler['_receivingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ inbox: { size: 1 } } as any); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getChannelAccount') + .mockResolvedValue({ outbox: { size: 2 } } as any); + jest.spyOn(initArgs.logger as Logger, 'info'); + jest + .spyOn(ccuHandler['_db'], 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + jest + .spyOn(ccuHandler['_sendingChainAPIClient'], 'getSavedInclusionProofAtHeight') + .mockResolvedValue({ + proof: { + queries: [ + { + bitmap: Buffer.alloc(1), + key: ccuHandler['_outboxKeyForInclusionProof'], + value: Buffer.alloc(2), + }, + ], + siblingHashes: [], + }, + }); + jest.spyOn(inboxUtility, 'calculateMessageWitnesses').mockReturnValue({ + crossChainMessages: [Buffer.alloc(2)], + lastCCMToBeSent, + messageWitnessHashes, + }); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(sampleLastCertificate.validatorsHash) + .mockResolvedValue(sampleValidatorsDataAtLastCertificate); + + when(ccuHandler['_db'].getValidatorsDataByHash) + .calledWith(newCertificate.validatorsHash) + .mockResolvedValue(validatorsDataAtNewCertificate); + + const result = await ccuHandler.computeCCU(sampleLastCertificate, sampleLastSentCCM); + + const { activeValidatorsUpdate, certificateThreshold } = calculateActiveValidatorsUpdate( + sampleValidatorsDataAtLastCertificate, + validatorsDataAtNewCertificate, + ); + + expect(result).toEqual({ + ccuParams: { + sendingChainID: config.ownChainID, + activeValidatorsUpdate, + certificate: codec.encode(certificateSchema, newCertificate), + certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + }, + lastCCMToBeSent, + }); + }); + }); + + describe('submitCCU', () => { + const accountNonce = '2'; + const crossChainMessages = [Buffer.alloc(2)]; + // const lastCCMToBeSent = { ...getSampleCCM(), height: 2, outboxSize: 2 }; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + }; + const validatorsDataAtNewCertificate: ValidatorsDataWithHeight = { + validators: [ + { + address: cryptography.utils.getRandomBytes(32), + bftWeight: BigInt(2), + blsKey: cryptography.utils.getRandomBytes(54), + }, + ], + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + certificateThreshold: BigInt(1), + height: 20, + }; + let ccuParams: CrossChainUpdateTransactionParams; + const { privateKey, publicKey } = testing.fixtures.keysList.keys[0]; + + let ccuTx: Transaction; + + beforeEach(() => { + ccuHandler.load(initArgs); + (ccuHandler['_db'] as any).privateKey = Buffer.from(privateKey, 'hex'); + receivingChainAPIClientMock.getAuthAccountNonceFromPublicKey.mockResolvedValue(accountNonce); + (ccuHandler as any)['_isReceivingChainMainchain'] = true; + jest.spyOn(ccuHandler as any, '_getCcuFee').mockResolvedValue(BigInt(10000000)); + (ccuHandler as any)['_isSaveCCU'] = false; + receivingChainAPIClientMock.postTransaction.mockResolvedValue({ + transactionId: cryptography.utils.hash(Buffer.from('txID')).toString('hex'), + }); + receivingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: false }); + jest.spyOn(initArgs.logger as Logger, 'info'); + + const { activeValidatorsUpdate, certificateThreshold } = calculateActiveValidatorsUpdate( + sampleValidatorsDataAtLastCertificate, + validatorsDataAtNewCertificate, + ); + ccuParams = { + sendingChainID: config.ownChainID, + activeValidatorsUpdate, + certificate: codec.encode(certificateSchema, newCertificate), + certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + }; + ccuTx = new Transaction({ + module: MODULE_NAME_INTEROPERABILITY, + command: COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, + nonce: BigInt(accountNonce), + senderPublicKey: Buffer.from(publicKey, 'hex'), + params: codec.encode(ccuParamsSchema, ccuParams), + signatures: [], + fee: BigInt(10000000), + }); + }); + + it('should throw error if there is no privateKey', async () => { + (ccuHandler['_db'] as any).privateKey = undefined; + await expect(ccuHandler['submitCCU'](ccuParams, 'txID')).rejects.toThrow( + 'There is no key enabled to submit CCU.', + ); + }); + + it('should throw error when receiving chain is syncing', async () => { + receivingChainAPIClientMock.getNodeInfo.mockResolvedValue({ syncing: true }); + await expect(ccuHandler['submitCCU'](ccuParams, 'txID')).rejects.toThrow( + 'Receiving node is syncing.', + ); + }); + + it('should return undefined when the tx id is equal to last sent CCU', async () => { + ccuTx.sign(config.receivingChainID as Buffer, Buffer.from(privateKey, 'hex')); + const result = await ccuHandler['submitCCU'](ccuParams, ccuTx.id.toString('hex')); + expect(result).toBeUndefined(); + }); + + it('should send CCU transaction and set CCU transaction in the DB', async () => { + ccuTx.sign(config.receivingChainID as Buffer, Buffer.from(privateKey, 'hex')); + receivingChainAPIClientMock.postTransaction.mockResolvedValue({ + transactionId: ccuTx.id.toString('hex'), + }); + + const result = await ccuHandler['submitCCU'](ccuParams, 'randomTxID'); + + expect(result).toEqual(ccuTx.id.toString('hex')); + expect(chainConnectorDBMock.setCCUTransaction).toHaveBeenCalledWith(ccuTx.toObject()); + }); + }); + + describe('_findCertificate', () => { + beforeEach(() => { + ccuHandler.load(initArgs); + }); + + it('should return undefined if no aggregate commit is found when last certificate height is zero', async () => { + chainConnectorDBMock.getAggregateCommitBetweenHeights.mockResolvedValue([]); + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 0, + }; + const result = await ccuHandler['_findCertificate'](); + + expect(result).toBeUndefined(); + }); + + it('should return first certificate when last certificate height is zero', async () => { + const firstAggregateCommit = { + aggregationBits: Buffer.alloc(1), + certificateSignature: cryptography.utils.getRandomBytes(54), + height: 2, + }; + chainConnectorDBMock.getAggregateCommitBetweenHeights.mockResolvedValue([ + firstAggregateCommit, + ]); + + const blockHeaderAtAggregateCommitHeight = testing.createFakeBlockHeader({ height: 2 }); + const certificate = getCertificateFromAggregateCommitByBlockHeader( + firstAggregateCommit, + blockHeaderAtAggregateCommitHeight.toObject(), + ); + chainConnectorDBMock.getBlockHeaderByHeight.mockResolvedValue( + blockHeaderAtAggregateCommitHeight, + ); + ccuHandler['_lastCertificate'] = { + ...sampleLastCertificate, + height: 0, + }; + const result = await ccuHandler['_findCertificate'](); + + expect(result).toEqual(certificate); + }); + + it('should return undefined if getNextCertificateFromAggregateCommits returns no certificate', async () => { + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 2 }); + const sampleCertificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + }; + const bftHeights = { + maxHeightPrevoted: 12, + maxHeightPrecommitted: 2, + maxHeightCertified: 2, + }; + + jest + .spyOn(certificateUtility, 'getNextCertificateFromAggregateCommits') + .mockResolvedValue(sampleCertificate); + sendingChainAPIClientMock.getBFTHeights.mockResolvedValue(bftHeights); + + ccuHandler['_lastCertificate'] = sampleLastCertificate; + const result = await ccuHandler['_findCertificate'](); + + expect(sendingChainAPIClientMock.getBFTHeights).toHaveBeenCalled(); + expect(certificateUtility.getNextCertificateFromAggregateCommits).toHaveBeenCalledWith( + ccuHandler['_db'], + bftHeights, + ccuHandler['_lastCertificate'], + ); + expect(result).toEqual(sampleCertificate); + }); + }); + + describe('_getCcuFee', () => { + const accountNonce = '2'; + const userInitializationFee = BigInt('2000000000'); + const crossChainMessages = [Buffer.alloc(2)]; + const messageWitnessHashes: Buffer[] = []; + const sampleBlockHeader = testing.createFakeBlockHeader({ height: 5 }); + const newCertificate: Certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot as Buffer, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + }; + const validatorsDataAtNewCertificate: ValidatorsDataWithHeight = { + validators: [ + { + address: cryptography.utils.getRandomBytes(32), + bftWeight: BigInt(2), + blsKey: cryptography.utils.getRandomBytes(54), + }, + ], + validatorsHash: sampleBlockHeader.validatorsHash as Buffer, + certificateThreshold: BigInt(1), + height: 20, + }; + + let ccuParams: CrossChainUpdateTransactionParams; + const { publicKey } = testing.fixtures.keysList.keys[0]; + + let ccuTx: Transaction; + + beforeEach(() => { + ccuHandler.load(initArgs); + const { activeValidatorsUpdate, certificateThreshold } = calculateActiveValidatorsUpdate( + sampleValidatorsDataAtLastCertificate, + validatorsDataAtNewCertificate, + ); + + ccuParams = { + sendingChainID: config.ownChainID, + activeValidatorsUpdate, + certificate: codec.encode(certificateSchema, newCertificate), + certificateThreshold, + inboxUpdate: { + crossChainMessages, + messageWitnessHashes, + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + }; + ccuTx = new Transaction({ + module: MODULE_NAME_INTEROPERABILITY, + command: COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, + nonce: BigInt(accountNonce), + senderPublicKey: Buffer.from(publicKey, 'hex'), + params: codec.encode(ccuParamsSchema, ccuParams), + signatures: [], + fee: BigInt(10000000), + }); + }); + + it('should return min fee including initialization fee when no user account exists', async () => { + receivingChainAPIClientMock.hasUserTokenAccount.mockResolvedValue({ exists: false }); + receivingChainAPIClientMock.getTokenInitializationFee.mockResolvedValue({ + userAccount: userInitializationFee.toString(), + }); + + (ccuHandler as any)['_ccuFee'] = '1'; + const { fee, ...ccuWithoutFee } = ccuTx.toObject(); + const computedMinFee = transactions.computeMinFee( + { ...ccuWithoutFee, params: ccuParams } as any, + ccuParamsSchema, + { + additionalFee: userInitializationFee, + }, + ); + + const computedFee = await ccuHandler['_getCcuFee']({ + ...ccuWithoutFee, + params: ccuParams, + } as any); + expect(computedFee).toEqual(computedMinFee); + }); + + it('should return min fee excluding initialization fee when user account exists', async () => { + receivingChainAPIClientMock.hasUserTokenAccount.mockResolvedValue({ exists: true }); + const { fee, ...ccuWithoutFee } = ccuTx.toObject(); + + const computedFee = await ccuHandler['_getCcuFee']({ + ...ccuWithoutFee, + params: ccuParams, + } as any); + + expect(receivingChainAPIClientMock.getTokenInitializationFee).not.toHaveBeenCalled(); + expect(computedFee).toEqual(BigInt(ccuHandler['_ccuFee'])); + }); + + it('should return ccuFee when computed min fee is lower than ccuFee', async () => { + receivingChainAPIClientMock.hasUserTokenAccount.mockResolvedValue({ exists: false }); + receivingChainAPIClientMock.getTokenInitializationFee.mockResolvedValue({ + userAccount: userInitializationFee.toString(), + }); + + const { fee, ...ccuWithoutFee } = ccuTx.toObject(); + + const computedFee = await ccuHandler['_getCcuFee']({ + ...ccuWithoutFee, + params: ccuParams, + } as any); + expect(computedFee).toEqual(BigInt(ccuHandler['_ccuFee']) + userInitializationFee); + }); + }); +}); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/certificate_generation.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/certificate_generation.spec.ts index 60c6f7ec4a..ddb2c56eb1 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/certificate_generation.spec.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/certificate_generation.spec.ts @@ -14,6 +14,7 @@ import { BFTHeights, + LastCertificate, chain, computeUnsignedCertificateFromBlockHeader, cryptography, @@ -21,10 +22,11 @@ import { } from 'lisk-sdk'; import { checkChainOfTrust, - getCertificateFromAggregateCommit, + getCertificateFromAggregateCommitByBlockHeader, getNextCertificateFromAggregateCommits, } from '../../src/certificate_generation'; import { ADDRESS_LENGTH, BLS_PUBLIC_KEY_LENGTH, HASH_LENGTH } from '../../src/constants'; +import { ChainConnectorDB } from '../../src/db'; describe('certificate generation', () => { const sampleSizeArray = new Array(10).fill(0); @@ -84,6 +86,7 @@ describe('certificate generation', () => { }, ], validatorsHash: lastValidatorsHash, + height: lastCertifiedBlock.height, }; const sampleValidatorsData = { @@ -101,6 +104,7 @@ describe('certificate generation', () => { }, ], validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), + height: lastCertifiedBlock.height, }; const bftHeights: BFTHeights = { @@ -113,12 +117,6 @@ describe('certificate generation', () => { blsKeyToBFTWeight[sampleValidatorsData.validators[1].blsKey.toString('hex')] = BigInt(2); describe('getCertificateFromAggregateCommit', () => { - it('should throw error if block header is not found for the aggregateCommit height', () => { - expect(() => - getCertificateFromAggregateCommit(aggregateCommitsSample[1], [sampleBlockHeaders[0]]), - ).toThrow('No block header found for the given aggregate height'); - }); - it('should compute Certificate from block header', () => { const firstBlockHeader = sampleBlockHeaders[0]; const { aggregateCommit } = firstBlockHeader; @@ -132,62 +130,92 @@ describe('certificate generation', () => { signature: aggregateCommit.certificateSignature, }; - const computedCertificate = getCertificateFromAggregateCommit(aggregateCommit, [ + const computedCertificate = getCertificateFromAggregateCommitByBlockHeader( + aggregateCommit, firstBlockHeader, - ]); + ); expect(computedCertificate).toEqual(expectedCertificate); }); }); describe('checkChainOfTrust', () => { - it('should throw error when there is no block header at {aggregateCommit.height - 1}', () => { - expect(() => + let chainConnectorDB: ChainConnectorDB; + beforeEach(() => { + chainConnectorDB = { + getBlockHeaderByHeight: jest.fn(), + getValidatorsDataByHash: jest.fn(), + getAggregateCommitByHeight: jest.fn(), + } as any; + }); + + it('should throw error when there is no block header at {aggregateCommit.height - 1}', async () => { + jest.spyOn(chainConnectorDB, 'getBlockHeaderByHeight').mockResolvedValue(undefined); + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(validatorsDataAtLastCertifiedHeight); + await expect( checkChainOfTrust( lastValidatorsHash, blsKeyToBFTWeight, validatorsDataAtLastCertifiedHeight.certificateThreshold, aggregateCommitsSample[3], - [lastCertifiedBlock], - [validatorsDataAtLastCertifiedHeight], + chainConnectorDB, ), - ).toThrow( + ).rejects.toThrow( 'No block header found for the given the previous height 5 of aggregate commit at height 6 when calling checkChainOfTrust.', ); }); - it('should throw error when there is no validatorsData at {aggregateCommit.height - 1}', () => { - expect(() => + it('should throw error when there is no validatorsData at {aggregateCommit.height - 1}', async () => { + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[2]); + jest.spyOn(chainConnectorDB, 'getValidatorsDataByHash').mockResolvedValue(undefined); + await expect( checkChainOfTrust( lastValidatorsHash, blsKeyToBFTWeight, validatorsDataAtLastCertifiedHeight.certificateThreshold, aggregateCommitsSample[2], - sampleBlockHeaders, - [validatorsDataAtLastCertifiedHeight], + chainConnectorDB, ), - ).toThrow('No validators data found for the given validatorsHash'); + ).rejects.toThrow('No validators data found for the given validatorsHash'); }); - it('should validate for valid lastValidatorsHash', () => { - const valid = checkChainOfTrust( + it('should validate for valid lastValidatorsHash', async () => { + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[1]); + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsData); + + const valid = await checkChainOfTrust( lastValidatorsHash, blsKeyToBFTWeight, validatorsDataAtLastCertifiedHeight.certificateThreshold, - aggregateCommitsSample[1], - sampleBlockHeaders, - [validatorsDataAtLastCertifiedHeight], + aggregateCommitsSample[2], + chainConnectorDB, ); expect(valid).toBe(true); }); - it('should return false when lastCertificateThreshold > { aggregateBFTWeight of the validators }', () => { + it('should return false when lastCertificateThreshold > { aggregateBFTWeight of the validators }', async () => { const aggregateHeightAtThree = aggregateCommitsSample[2]; const validatorsHashAtHeightThree = sampleBlockHeaders[2].validatorsHash; + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[2]); + const validatorsDataAtHeightThree = { ...sampleValidatorsData, validatorsHash: validatorsHashAtHeightThree, + height: sampleBlockHeaders[2].height, }; + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(validatorsDataAtHeightThree); /** * Configuration: @@ -197,24 +225,30 @@ describe('certificate generation', () => { * aggregationBit = '01' * lastCertificateThreshold = BigInt(3) */ - const valid = checkChainOfTrust( + const valid = await checkChainOfTrust( lastCertifiedBlock.validatorsHash, blsKeyToBFTWeight, BigInt(3), // Last certificate threshold > aggregateBFT weight aggregateHeightAtThree, - sampleBlockHeaders, - [validatorsDataAtHeightThree], + chainConnectorDB, ); expect(valid).toBe(false); }); - it('should validate for blockHeader at height 4', () => { + it('should validate for blockHeader at height 4', async () => { const aggregateHeightAtFour = aggregateCommitsSample[2]; const validatorsHashAtHeightThree = sampleBlockHeaders[2].validatorsHash; + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[2]); const validatorsDataAtHeightThree = { ...sampleValidatorsData, validatorsHash: validatorsHashAtHeightThree, + height: sampleBlockHeaders[2].height, }; + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(validatorsDataAtHeightThree); /** * Configuration: @@ -224,56 +258,79 @@ describe('certificate generation', () => { * aggregationBit = '01' * lastCertificateThreshold = BigInt(1) */ - const valid = checkChainOfTrust( + const valid = await checkChainOfTrust( lastCertifiedBlock.validatorsHash, blsKeyToBFTWeight, validatorsDataAtLastCertifiedHeight.certificateThreshold, aggregateHeightAtFour, - sampleBlockHeaders, - [validatorsDataAtHeightThree], + chainConnectorDB, ); expect(valid).toBe(true); }); }); describe('getNextCertificateFromAggregateCommits', () => { - it('should throw error when no block header found at last certified height', () => { - expect(() => - getNextCertificateFromAggregateCommits( - [], - aggregateCommitsSample, - [sampleValidatorsData], - bftHeights, - { height: lastCertifiedBlock.height } as any, - ), - ).toThrow('No block header found for the last certified height'); + let chainConnectorDB: ChainConnectorDB; + let lastCertificate: LastCertificate; + + beforeEach(() => { + chainConnectorDB = { + getBlockHeaderByHeight: jest.fn(), + getValidatorsDataByHash: jest.fn(), + getAggregateCommitByHeight: jest.fn(), + } as any; + + lastCertificate = { + height: lastCertifiedBlock.height, + stateRoot: lastCertifiedBlock.stateRoot, + timestamp: lastCertifiedBlock.timestamp, + validatorsHash: lastCertifiedBlock.validatorsHash, + }; }); - it('should throw error when no validators data found at last certified height', () => { - expect(() => - getNextCertificateFromAggregateCommits( - sampleBlockHeaders, - aggregateCommitsSample, - [], - bftHeights, - { height: lastCertifiedBlock.height } as any, - ), - ).toThrow('No validatorsHash preimage data present for the given validatorsHash'); + it('should throw error when no block header found at last certified height', async () => { + jest.spyOn(chainConnectorDB, 'getBlockHeaderByHeight').mockResolvedValue(undefined); + + await expect( + getNextCertificateFromAggregateCommits(chainConnectorDB, bftHeights, lastCertificate), + ).rejects.toThrow('No block header found for the last certified height'); }); - it('should return undefined when certificate is found through chainOfTrust', () => { - expect( - getNextCertificateFromAggregateCommits( - sampleBlockHeaders, - [aggregateCommitsSample[2]], - [validatorsDataAtLastCertifiedHeight], - bftHeights, - { height: lastCertifiedBlock.height } as any, - ), - ).toBeUndefined(); + it('should throw error when no validators data found at last certified height', async () => { + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[1]); + jest.spyOn(chainConnectorDB, 'getValidatorsDataByHash').mockResolvedValue(undefined); + + await expect( + getNextCertificateFromAggregateCommits(chainConnectorDB, bftHeights, lastCertificate), + ).rejects.toThrow('No validatorsHash preimage data present for the given validatorsHash'); }); - it('should return a valid certificate passing chainOfTrust check', () => { + it('should return undefined when certificate is found through chainOfTrust', async () => { + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[1]); + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(sampleValidatorsData); + + await expect( + getNextCertificateFromAggregateCommits(chainConnectorDB, bftHeights, lastCertificate), + ).resolves.toBeUndefined(); + }); + + it('should return a valid certificate passing chainOfTrust check', async () => { + jest + .spyOn(chainConnectorDB, 'getBlockHeaderByHeight') + .mockResolvedValue(sampleBlockHeaders[1]); + jest + .spyOn(chainConnectorDB, 'getValidatorsDataByHash') + .mockResolvedValue(validatorsDataAtLastCertifiedHeight); + jest + .spyOn(chainConnectorDB, 'getAggregateCommitByHeight') + .mockResolvedValue(aggregateCommitsSample[1]); + const secondBlockHeader = sampleBlockHeaders[1]; const unsignedCertificate = computeUnsignedCertificateFromBlockHeader( new chain.BlockHeader(secondBlockHeader), @@ -284,15 +341,9 @@ describe('certificate generation', () => { signature: secondBlockHeader.aggregateCommit.certificateSignature, }; - expect( - getNextCertificateFromAggregateCommits( - sampleBlockHeaders, - aggregateCommitsSample, - [validatorsDataAtLastCertifiedHeight, sampleValidatorsData], - bftHeights, - { height: lastCertifiedBlock.height } as any, - ), - ).toEqual(expectedCertificate); + await expect( + getNextCertificateFromAggregateCommits(chainConnectorDB, bftHeights, lastCertificate), + ).resolves.toEqual(expectedCertificate); }); }); }); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/chain_connector_plugin.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/chain_connector_plugin.spec.ts new file mode 100644 index 0000000000..f03c46535a --- /dev/null +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/chain_connector_plugin.spec.ts @@ -0,0 +1,237 @@ +/* + * Copyright © 2024 Lisk Foundation + * + * See the LICENSE file at the top-level directory of this distribution + * for licensing information. + * + * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, + * no part of this software, including this file, may be copied, modified, + * propagated, or distributed except according to the terms contained in the + * LICENSE file. + * + * Removal or modification of this copyright notice is prohibited. + */ + +import { + ApplicationConfigForPlugin, + apiClient, + cryptography, + db, + getMainchainID, + testing, +} from 'lisk-sdk'; +import { ChainConnectorPlugin } from '../../src/chain_connector_plugin'; +import { CCU_FREQUENCY, CCU_TOTAL_CCM_SIZE } from '../../src/constants'; +import { Logger } from '../../src/types'; +import * as dbApi from '../../src/db'; + +describe('ChainConnectorPlugin', () => { + const ownChainID = Buffer.from('04000000', 'hex'); + const appConfigForPlugin: ApplicationConfigForPlugin = { + ...testing.fixtures.defaultConfig, + genesis: { + chainID: ownChainID.toString('hex'), + } as any, + rpc: { + modes: ['ipc'], + port: 8080, + host: '127.0.0.1', + accessControlAllowOrigin: '*', + }, + system: { + dataPath: '~/.lisk/ChainConnectorPlugin/test/', + } as any, + }; + const defaultPrivateKey = + '6c5e2b24ff1cc99da7a49bd28420b93b2a91e2e2a3b0a0ce07676966b707d3c2859bbd02747cf8e26dab592c02155dfddd4a16b0fe83fd7e7ffaec0b5391f3f7'; + const defaultPassword = '123'; + const defaultCCUFee = '500000'; + + const getApiClientMock = () => ({ + disconnect: jest.fn(), + invoke: jest.fn(), + subscribe: jest.fn(), + connect: jest.fn(), + node: { getNodeInfo: jest.fn() }, + }); + + let encryptedKey; + let defaultEncryptedPrivateKey; + + let defaultConfig: Record; + let logger: Logger; + let receivingChainAPIClientMock; + let chainConnectorPlugin: ChainConnectorPlugin; + + beforeEach(async () => { + encryptedKey = await cryptography.encrypt.encryptMessageWithPassword( + Buffer.from(defaultPrivateKey, 'hex'), + defaultPassword, + { + kdfparams: { + iterations: 1, + memorySize: 256, + parallelism: 1, + }, + }, + ); + defaultEncryptedPrivateKey = cryptography.encrypt.stringifyEncryptedMessage(encryptedKey); + + defaultConfig = { + receivingChainIPCPath: '~/.lisk/mainchain', + ccuFee: defaultCCUFee, + encryptedPrivateKey: defaultEncryptedPrivateKey, + ccuFrequency: CCU_FREQUENCY, + maxCCUSize: CCU_TOTAL_CCM_SIZE, + ccuSaveLimit: 1, + isSaveCCU: false, + registrationHeight: 1, + receivingChainID: getMainchainID(ownChainID).toString('hex'), + }; + + receivingChainAPIClientMock = getApiClientMock(); + + jest + .spyOn(apiClient, 'createIPCClient') + .mockResolvedValue(receivingChainAPIClientMock as never); + }); + + describe('init', () => { + beforeEach(() => { + logger = testing.mocks.loggerMock; + chainConnectorPlugin = new ChainConnectorPlugin(); + }); + + it('Should throw error when maxCCUSize > CCU_TOTAL_CCM_SIZE', async () => { + await expect( + chainConnectorPlugin.init({ + appConfig: appConfigForPlugin, + config: { + loadAsChildProcess: false, + ...defaultConfig, + maxCCUSize: CCU_TOTAL_CCM_SIZE + 1000, + }, + logger, + }), + ).rejects.toThrow('must be <= 10240'); + }); + + it('Should assign config properties', async () => { + await chainConnectorPlugin.init({ + appConfig: appConfigForPlugin, + config: { + loadAsChildProcess: false, + ...defaultConfig, + }, + logger, + }); + expect(chainConnectorPlugin['_receivingChainID'].toString('hex')).toEqual( + defaultConfig.receivingChainID, + ); + expect(chainConnectorPlugin['_blockEventHandler']).toBeDefined(); + }); + }); + + describe('load', () => { + const endpointMock = { + load: jest.fn().mockResolvedValue({}), + }; + const sendingChainClientMock = { + connect: jest.fn().mockResolvedValue({}), + }; + const blockEventHandlerMock = { + load: jest.fn().mockResolvedValue({}), + }; + + const mockLoadFunction = () => { + (chainConnectorPlugin as any)['endpoint'] = endpointMock; + (chainConnectorPlugin as any)['_sendingChainClient'] = sendingChainClientMock; + (chainConnectorPlugin as any)['_blockEventHandler'] = blockEventHandlerMock; + jest.spyOn(dbApi, 'getDBInstance').mockResolvedValue(new db.InMemoryDatabase() as never); + jest.spyOn(blockEventHandlerMock, 'load'); + jest.spyOn(endpointMock, 'load'); + jest.spyOn(sendingChainClientMock, 'connect'); + }; + + beforeEach(() => { + chainConnectorPlugin = new ChainConnectorPlugin(); + }); + + it('should throw error when receivingChainID is not in the same network', async () => { + await chainConnectorPlugin.init({ + appConfig: appConfigForPlugin, + config: { + loadAsChildProcess: false, + ...defaultConfig, + receivingChainID: '9999999', + }, + logger, + }); + mockLoadFunction(); + + await expect(chainConnectorPlugin.load()).rejects.toThrow( + 'Receiving Chain ID network does not match the sending chain network', + ); + }); + + it('should call load on endpoint and blockEventHandler successfully', async () => { + await chainConnectorPlugin.init({ + appConfig: appConfigForPlugin, + config: { + loadAsChildProcess: false, + ...defaultConfig, + }, + logger, + }); + mockLoadFunction(); + await chainConnectorPlugin.load(); + + expect(chainConnectorPlugin['endpoint'].load).toHaveBeenCalledWith( + defaultConfig.encryptedPrivateKey, + chainConnectorPlugin['_chainConnectorDB'], + ); + expect(chainConnectorPlugin['_blockEventHandler'].load).toHaveBeenCalledTimes(1); + expect(chainConnectorPlugin['_sendingChainClient'].connect).toHaveBeenCalledTimes(1); + }); + }); + + describe('unload', () => { + const sendingChainClientMock = { + disconnect: jest.fn().mockResolvedValue({}), + }; + const receivingChainClientMock = { + disconnect: jest.fn().mockResolvedValue({}), + }; + const dbMock = { + close: jest.fn().mockResolvedValue({}), + }; + + beforeEach(() => { + chainConnectorPlugin = new ChainConnectorPlugin(); + (chainConnectorPlugin as any)['_sendingChainClient'] = sendingChainClientMock; + (chainConnectorPlugin as any)['_receivingChainClient'] = receivingChainClientMock; + (chainConnectorPlugin as any)['_chainConnectorDB'] = dbMock; + + jest.spyOn(chainConnectorPlugin['_sendingChainClient'], 'disconnect').mockResolvedValue(); + jest.spyOn(chainConnectorPlugin['_receivingChainClient'], 'disconnect').mockResolvedValue(); + jest.spyOn(chainConnectorPlugin['_chainConnectorDB'], 'close').mockReturnValue(); + }); + + it('should call disconnect when both clients are available', async () => { + await chainConnectorPlugin.unload(); + + expect(sendingChainClientMock.disconnect).toHaveBeenCalledTimes(1); + expect(receivingChainClientMock.disconnect).toHaveBeenCalledTimes(1); + expect(dbMock.close).toHaveBeenCalledTimes(1); + }); + + it('should not call _receivingChainClient when its undefined', async () => { + (chainConnectorPlugin as any)['_receivingChainClient'] = undefined; + await chainConnectorPlugin.unload(); + + expect(sendingChainClientMock.disconnect).toHaveBeenCalledTimes(1); + expect(receivingChainClientMock.disconnect).toHaveBeenCalledTimes(0); + expect(dbMock.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/db.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/db.spec.ts index 38dea0d0b1..808dad87b7 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/db.spec.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/db.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-commented-out-tests */ /* * Copyright © 2022 Lisk Foundation * @@ -13,31 +14,29 @@ */ import { - AggregateCommit, db, testing, cryptography, - chain, - SubmitMainchainCrossChainUpdateCommand, + EMPTY_BYTES, CROSS_CHAIN_COMMAND_NAME_TRANSFER, + SubmitMainchainCrossChainUpdateCommand, MODULE_NAME_INTEROPERABILITY, + chain, + codec, + certificateSchema, } from 'lisk-sdk'; import * as fs from 'fs-extra'; import { homedir } from 'os'; import { join } from 'path'; -import { ChainConnectorStore, getDBInstance } from '../../src/db'; +import { ChainConnectorDB, getDBInstance } from '../../src/db'; import { ADDRESS_LENGTH, BLS_PUBLIC_KEY_LENGTH } from '../../src/constants'; -import { - BlockHeader, - CCMsFromEvents, - LastSentCCMWithHeight, - ValidatorsData, -} from '../../src/types'; +import { BlockHeader, CCMWithHeight, LastSentCCM, ValidatorsDataWithHeight } from '../../src/types'; +import * as dbApi from '../../src/db'; jest.mock('fs-extra'); const mockedFsExtra = fs as jest.Mocked; -describe('Plugins DB', () => { +describe('DB', () => { const unresolvedRootPath = '~/.lisk/devnet'; const dbName = 'lisk-framework-chain-connector-plugin.db'; @@ -63,77 +62,175 @@ describe('Plugins DB', () => { expect(mockedFsExtra.ensureDir).toHaveBeenCalledWith(dirPath); }); - describe('ChainConnectorStore', () => { - let chainConnectorStore: ChainConnectorStore; + describe('ChainConnectorDB', () => { + const customUnresolvedRootPath = '~/.lisk/devnet/custom/path'; + let chainConnectorDB: ChainConnectorDB; - beforeEach(() => { - chainConnectorStore = new ChainConnectorStore(new db.InMemoryDatabase() as never); + beforeEach(async () => { + jest.spyOn(dbApi, 'getDBInstance').mockResolvedValue(new db.InMemoryDatabase() as never); + chainConnectorDB = new ChainConnectorDB(); + await chainConnectorDB.load(customUnresolvedRootPath); }); describe('constructor', () => { it('should assign DB in the constructor', () => { - expect(chainConnectorStore['_db']).toBeInstanceOf(db.InMemoryDatabase); + expect(chainConnectorDB['_db']).toBeInstanceOf(db.InMemoryDatabase); }); }); - describe('blockHeaders', () => { + describe('blockHeader', () => { let sampleBlockHeaders: BlockHeader[]; - beforeEach(() => { - sampleBlockHeaders = new Array(4).fill(0).map(() => { - const { id, ...block } = testing.createFakeBlockHeader().toObject(); + beforeEach(async () => { + sampleBlockHeaders = [1, 2, 3, 4].map(index => + testing.createFakeBlockHeader({ height: index }).toObject(), + ); - return block; + for (const blockHeader of sampleBlockHeaders) { + await chainConnectorDB.saveToDBOnNewBlock(blockHeader); + } + }); + describe('saveToDBOnNewBlock', () => { + it('should save block header and aggregateCommit', async () => { + await chainConnectorDB.saveToDBOnNewBlock(sampleBlockHeaders[0]); + const blockHeader = await chainConnectorDB.getBlockHeaderByHeight( + sampleBlockHeaders[0].height, + ); + + expect(blockHeader).toEqual(sampleBlockHeaders[0]); }); }); - it('should return empty array when there is no record', async () => { - await expect(chainConnectorStore.getBlockHeaders()).resolves.toEqual([]); + describe('getBlockHeaderByHeight', () => { + it('should get blockHeader by height', async () => { + const blockHeader = await chainConnectorDB.getBlockHeaderByHeight( + sampleBlockHeaders[0].height, + ); + expect(blockHeader).toEqual(sampleBlockHeaders[0]); + }); }); - it('should return list of blockHeaders', async () => { - await chainConnectorStore.setBlockHeaders(sampleBlockHeaders); + describe('getBlockHeadersBetweenHeights', () => { + it('should return block headers between the given heights', async () => { + const fromHeight = 2; + const toHeight = 4; + const blockHeaders = await chainConnectorDB.getBlockHeadersBetweenHeights( + fromHeight, + toHeight, + ); + + expect(blockHeaders).toHaveLength(toHeight + 1 - fromHeight); + expect(blockHeaders.reverse()).toEqual( + sampleBlockHeaders.filter(b => b.height >= fromHeight && b.height <= toHeight), + ); + }); + }); - await expect(chainConnectorStore.getBlockHeaders()).resolves.toEqual(sampleBlockHeaders); + describe('deleteBlockHeadersBetweenHeight', () => { + it('should delete block headers between the given heights', async () => { + const fromHeight = 2; + const toHeight = 4; + await chainConnectorDB.deleteBlockHeadersBetweenHeight(fromHeight, toHeight); + const blockHeaders = await chainConnectorDB.getBlockHeadersBetweenHeights( + fromHeight, + toHeight, + ); + + expect(blockHeaders).toHaveLength(0); + }); }); }); - describe('aggregateCommits', () => { - let sampleAggregateCommits: AggregateCommit[]; + describe('aggregateCommit', () => { + let sampleBlockHeaders: BlockHeader[]; - beforeEach(() => { - sampleAggregateCommits = [ - { - aggregationBits: Buffer.alloc(1), - certificateSignature: cryptography.utils.getRandomBytes(32), - height: 2, - }, - { - aggregationBits: Buffer.alloc(1), - certificateSignature: cryptography.utils.getRandomBytes(32), - height: 2, - }, - ]; + beforeEach(async () => { + sampleBlockHeaders = [10, 11, 12, 13].map(index => + testing + .createFakeBlockHeader({ + height: index, + aggregateCommit: { + aggregationBits: Buffer.alloc(1), + certificateSignature: cryptography.utils.hash(Buffer.alloc(2)), + height: index - 4, + }, + }) + .toObject(), + ); + for (const blockHeader of sampleBlockHeaders) { + await chainConnectorDB.saveToDBOnNewBlock(blockHeader); + } }); - it('should return empty array when there is no record', async () => { - await expect(chainConnectorStore.getAggregateCommits()).resolves.toEqual([]); + describe('getAggregateCommitByHeight', () => { + it('should return aggregateCommit for the given height', async () => { + await expect( + chainConnectorDB.getAggregateCommitByHeight( + sampleBlockHeaders[0].aggregateCommit.height, + ), + ).resolves.toEqual(sampleBlockHeaders[0].aggregateCommit); + }); + + it('should return undefined for the given height where no aggregateCommit exists', async () => { + await expect( + chainConnectorDB.getAggregateCommitByHeight( + sampleBlockHeaders[0].aggregateCommit.height + 1000, + ), + ).resolves.toBeUndefined(); + }); }); - it('should return list of aggregateCommits', async () => { - await chainConnectorStore.setAggregateCommits(sampleAggregateCommits); + describe('getAggregateCommitBetweenHeights', () => { + it('should return aggregateCommit between the given heights', async () => { + const fromHeight = 7; + const toHeight = 9; + const aggregateCommits = await chainConnectorDB.getAggregateCommitBetweenHeights( + fromHeight, + toHeight, + ); + + expect(aggregateCommits).toHaveLength(toHeight + 1 - fromHeight); + expect(aggregateCommits.reverse()).toEqual( + sampleBlockHeaders + .map(h => h.aggregateCommit) + .filter(b => b.height >= fromHeight && b.height <= toHeight), + ); + }); + }); - await expect(chainConnectorStore.getAggregateCommits()).resolves.toEqual( - sampleAggregateCommits, - ); + describe('deleteAggregateCommitsBetweenHeight', () => { + it('should delete block headers between the given heights', async () => { + const fromHeight = 2; + const toHeight = 4; + await chainConnectorDB.deleteAggregateCommitsBetweenHeight(fromHeight, toHeight); + const aggregateCommits = await chainConnectorDB.getAggregateCommitBetweenHeights( + fromHeight, + toHeight, + ); + + expect(aggregateCommits).toHaveLength(0); + }); + }); + + describe('deleteAggregateCommitByHeight', () => { + it('should delete block headers with the given height', async () => { + await chainConnectorDB.deleteAggregateCommitByHeight( + sampleBlockHeaders[0].aggregateCommit.height, + ); + const aggregateCommit = await chainConnectorDB.getAggregateCommitByHeight( + sampleBlockHeaders[0].aggregateCommit.height, + ); + + expect(aggregateCommit).toBeUndefined(); + }); }); }); - describe('validatorsHashPreimage', () => { - let sampleValidatorsData: ValidatorsData[]; + describe('validatorsData', () => { + let sampleValidatorsData: ValidatorsDataWithHeight[]; - beforeEach(() => { - sampleValidatorsData = new Array(2).fill(0).map(() => ({ + beforeEach(async () => { + sampleValidatorsData = [20, 21].map(index => ({ certificateThreshold: BigInt(68), validators: [ { @@ -148,95 +245,187 @@ describe('Plugins DB', () => { }, ], validatorsHash: cryptography.utils.getRandomBytes(54), + height: index, })); + + for (const validatorData of sampleValidatorsData) { + await chainConnectorDB.setValidatorsDataByHash( + validatorData.validatorsHash, + validatorData, + validatorData.height, + ); + } + }); + + describe('get/setValidatorsDataByHash', () => { + it('should return validators data by the given hash', async () => { + await expect( + chainConnectorDB.getValidatorsDataByHash(sampleValidatorsData[0].validatorsHash), + ).resolves.toEqual(sampleValidatorsData[0]); + }); + + it('should return undefined for hash with no validators data', async () => { + await expect( + chainConnectorDB.getValidatorsDataByHash(cryptography.utils.hash(EMPTY_BYTES)), + ).resolves.toBeUndefined(); + }); }); - it('should return empty array when there is no record', async () => { - await expect(chainConnectorStore.getValidatorsHashPreimage()).resolves.toEqual([]); + describe('deleteValidatorsDataByHash', () => { + it('should delete validators data for a given hash', async () => { + await chainConnectorDB.deleteValidatorsDataByHash(sampleValidatorsData[0].validatorsHash); + + await expect( + chainConnectorDB.getValidatorsDataByHash(sampleValidatorsData[0].validatorsHash), + ).resolves.toBeUndefined(); + }); }); - it('should return list of validatorsHashPreImage', async () => { - await chainConnectorStore.setValidatorsHashPreimage(sampleValidatorsData); + describe('deleteValidatorsHashByHeight', () => { + it('should delete validators data for a given height', async () => { + await chainConnectorDB.deleteValidatorsHashByHeight(sampleValidatorsData[0].height); - await expect(chainConnectorStore.getValidatorsHashPreimage()).resolves.toEqual( - sampleValidatorsData, - ); + await expect( + chainConnectorDB.getValidatorsDataByHeight(sampleValidatorsData[0].height), + ).resolves.toBeUndefined(); + }); + }); + + describe('deleteValidatorsHashBetweenHeights', () => { + it('should delete validators data between given heights', async () => { + const fromHeight = 20; + const toHeight = 21; + await chainConnectorDB.deleteValidatorsHashBetweenHeights(fromHeight, toHeight); + const validatorsDataAtFromHeight = await chainConnectorDB.getValidatorsDataByHeight( + fromHeight, + ); + const validatorsDataAtToHeight = await chainConnectorDB.getValidatorsDataByHeight( + toHeight, + ); + + expect(validatorsDataAtFromHeight).toBeUndefined(); + expect(validatorsDataAtToHeight).toBeUndefined(); + }); + }); + + describe('getAllValidatorsData', () => { + it('should return all the validators data present in the db', async () => { + const allValidatorsData = await chainConnectorDB.getAllValidatorsData(); + expect(allValidatorsData).toHaveLength(sampleValidatorsData.length); + }); }); }); - describe('crossChainMessages', () => { - let sampleCrossChainMessages: CCMsFromEvents[]; + describe('ccmByHeight', () => { + let sampleCrossChainMessages: Record; - beforeEach(() => { - sampleCrossChainMessages = [ - { - ccms: [ - { - crossChainCommand: 'transfer', - fee: BigInt(1), - module: 'token', - nonce: BigInt(10), - params: Buffer.alloc(2), - receivingChainID: Buffer.from('10000000', 'hex'), - sendingChainID: Buffer.from('10000001', 'hex'), - status: 1, - }, - ], - height: 10, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(2)], + beforeEach(async () => { + sampleCrossChainMessages = { + '10': [ + { + crossChainCommand: 'transfer', + fee: BigInt(1), + module: 'token', + nonce: BigInt(10), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('10000000', 'hex'), + sendingChainID: Buffer.from('10000001', 'hex'), + status: 1, + height: 10, }, - outboxSize: 2, - }, - { - ccms: [ - { - crossChainCommand: 'transfer', - fee: BigInt(2), - module: 'token', - nonce: BigInt(12), - params: Buffer.alloc(2), - receivingChainID: Buffer.from('01000000', 'hex'), - sendingChainID: Buffer.from('00000001', 'hex'), - status: 1, - }, - { - crossChainCommand: 'transfer', - fee: BigInt(2), - module: 'token', - nonce: BigInt(13), - params: Buffer.alloc(1), - receivingChainID: Buffer.from('01000000', 'hex'), - sendingChainID: Buffer.from('00000001', 'hex'), - status: 1, - }, - ], - height: 11, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(2)], + ], + '11': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(12), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 11, }, - outboxSize: 2, - }, - ]; + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(13), + params: Buffer.alloc(1), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 11, + }, + ], + '12': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(12), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 12, + }, + ], + '13': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(13), + params: Buffer.alloc(1), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 13, + }, + ], + }; + await chainConnectorDB.setCCMsByHeight(sampleCrossChainMessages['10'], 10); + await chainConnectorDB.setCCMsByHeight(sampleCrossChainMessages['11'], 11); + await chainConnectorDB.setCCMsByHeight(sampleCrossChainMessages['12'], 12); + await chainConnectorDB.setCCMsByHeight(sampleCrossChainMessages['13'], 13); }); - it('should return empty array when there is no record', async () => { - await expect(chainConnectorStore.getCrossChainMessages()).resolves.toEqual([]); - }); + describe('set/getCCMsByHeight', () => { + it('should return empty array when there is no record for a given height', async () => { + await expect(chainConnectorDB.getCCMsByHeight(14)).resolves.toEqual([]); + }); - it('should return list of crossChainMessages', async () => { - await chainConnectorStore.setCrossChainMessages(sampleCrossChainMessages); + it('should return list of crossChainMessages for a given height', async () => { + await expect(chainConnectorDB.getCCMsByHeight(11)).resolves.toEqual( + sampleCrossChainMessages['11'], + ); + }); + }); - await expect(chainConnectorStore.getCrossChainMessages()).resolves.toEqual( - sampleCrossChainMessages, - ); + describe('getCCMsBetweenHeights', () => { + it('should return all the ccms between then given heights', async () => { + const fromHeight = 10; + const toHeight = 12; + const ccms = await chainConnectorDB.getCCMsBetweenHeights(fromHeight, toHeight); + expect(ccms).toHaveLength( + sampleCrossChainMessages['10'].length + + sampleCrossChainMessages['11'].length + + sampleCrossChainMessages['12'].length, + ); + expect(ccms).toEqual( + [ + ...sampleCrossChainMessages['10'], + ...sampleCrossChainMessages['11'].reverse(), + ...sampleCrossChainMessages['12'], + ].reverse(), + ); + }); }); }); describe('lastSentCCM', () => { - let sampleLastSentCCM: LastSentCCMWithHeight; + let sampleLastSentCCM: LastSentCCM; beforeEach(() => { sampleLastSentCCM = { @@ -249,89 +438,142 @@ describe('Plugins DB', () => { receivingChainID: Buffer.from('04000000', 'hex'), sendingChainID: Buffer.from('04000001', 'hex'), status: 1, + outboxSize: 2, }; }); - it('should return undefined when there is no record', async () => { - await expect(chainConnectorStore.getLastSentCCM()).resolves.toBeUndefined(); - }); + describe('getLastSentCCM', () => { + it('should return undefined when there is no record', async () => { + await expect(chainConnectorDB.getLastSentCCM()).resolves.toBeUndefined(); + }); - it('should return lastSentCCM', async () => { - await chainConnectorStore.setLastSentCCM(sampleLastSentCCM); + it('should return lastSentCCM', async () => { + await chainConnectorDB.setLastSentCCM(sampleLastSentCCM); - await expect(chainConnectorStore.getLastSentCCM()).resolves.toEqual(sampleLastSentCCM); + await expect(chainConnectorDB.getLastSentCCM()).resolves.toEqual(sampleLastSentCCM); + }); }); }); - describe('listOfCCUs', () => { + describe('CCUs', () => { let listOfCCUs: chain.TransactionAttrs[]; + let listOfCCUsJSON: Record[]; beforeEach(() => { - listOfCCUs = [ - testing - .createTransaction({ - commandClass: SubmitMainchainCrossChainUpdateCommand as any, - module: MODULE_NAME_INTEROPERABILITY, - params: { - activeValidatorsUpdate: { - blsKeysUpdate: [], - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: Buffer.alloc(0), - }, - certificate: Buffer.alloc(1), - certificateThreshold: BigInt(1), - inboxUpdate: { - crossChainMessages: [], - messageWitnessHashes: [], - outboxRootWitness: { - bitmap: Buffer.alloc(1), - siblingHashes: [], - }, - }, - sendingChainID: Buffer.from('04000001', 'hex'), - }, - chainID: Buffer.from('04000001', 'hex'), - }) - .toObject(), - testing - .createTransaction({ - commandClass: SubmitMainchainCrossChainUpdateCommand as any, - module: MODULE_NAME_INTEROPERABILITY, - params: { - activeValidatorsUpdate: { - blsKeysUpdate: [], - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: Buffer.alloc(0), - }, - certificate: Buffer.alloc(2), - certificateThreshold: BigInt(2), - inboxUpdate: { - crossChainMessages: [], - messageWitnessHashes: [], - outboxRootWitness: { - bitmap: Buffer.alloc(1), - siblingHashes: [], - }, - }, - sendingChainID: Buffer.from('04000001', 'hex'), - }, - chainID: Buffer.from('04000001', 'hex'), - }) - .toObject(), - ].map(tx => { - const { id, ...txWithoutID } = tx; - return txWithoutID; + const sampleBlockHeader = testing.createFakeBlockHeader({}); + const certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash, + }; + + const params = { + activeValidatorsUpdate: { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: Buffer.alloc(0), + }, + certificate: codec.encode(certificateSchema, certificate), + certificateThreshold: BigInt(1), + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes: [], + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + sendingChainID: Buffer.from('04000001', 'hex'), + }; + + const paramsJSON = { + activeValidatorsUpdate: { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: '', + }, + certificate: { + aggregationBits: Buffer.alloc(2).toString('hex'), + blockID: sampleBlockHeader.id.toString('hex'), + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature.toString('hex'), + stateRoot: sampleBlockHeader.stateRoot?.toString('hex'), + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash?.toString('hex'), + }, + certificateThreshold: '1', + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes: [], + outboxRootWitness: { + bitmap: '00', + siblingHashes: [], + }, + }, + sendingChainID: '04000001', + }; + + const ccuOne = testing.createTransaction({ + commandClass: SubmitMainchainCrossChainUpdateCommand as any, + module: MODULE_NAME_INTEROPERABILITY, + params, + chainID: Buffer.from('04000001', 'hex'), }); + + const ccuTwo = testing.createTransaction({ + commandClass: SubmitMainchainCrossChainUpdateCommand as any, + module: MODULE_NAME_INTEROPERABILITY, + params, + chainID: Buffer.from('04000005', 'hex'), + fee: BigInt(100000), + nonce: BigInt(2), + }); + + listOfCCUs = [ + { ...ccuOne.toObject(), id: ccuOne.id }, + { ...ccuTwo.toObject(), id: ccuTwo.id }, + ].sort((a, b) => Number(BigInt(b.nonce) - BigInt(a.nonce))); + + listOfCCUsJSON = [ + { ...ccuTwo.toJSON(), params: { ...paramsJSON }, id: ccuTwo.id.toString('hex') }, + { ...ccuOne.toJSON(), params: { ...paramsJSON }, id: ccuOne.id.toString('hex') }, + ].sort((a, b) => Number(BigInt(b.nonce) - BigInt(a.nonce))); }); - it('should return empty array when there is no record', async () => { - await expect(chainConnectorStore.getListOfCCUs()).resolves.toEqual([]); + describe('listOfCCUs', () => { + it('should return empty array when there is no record', async () => { + const { list, total } = await chainConnectorDB.getListOfCCUs(); + expect(list).toEqual([]); + expect(total).toBe(0); + }); + + it('should return list of CCUs', async () => { + for (const ccu of listOfCCUs) { + await chainConnectorDB.setCCUTransaction(ccu); + } + + const { list, total } = await chainConnectorDB.getListOfCCUs(); + + expect(list).toEqual(listOfCCUsJSON); + expect(total).toBe(2); + }); }); - it('should return list of CCUs', async () => { - await chainConnectorStore.setListOfCCUs(listOfCCUs); + describe('deleteCCUTransaction', () => { + it('should delete ccu by ID when there is no record', async () => { + for (const ccu of listOfCCUs) { + await chainConnectorDB.setCCUTransaction(ccu); + } - await expect(chainConnectorStore.getListOfCCUs()).resolves.toEqual(listOfCCUs); + await chainConnectorDB.deleteCCUTransaction(listOfCCUs[0]?.id as Buffer); + const { list } = await chainConnectorDB.getListOfCCUs(); + const foundCCU = list.find(ccu => ccu.id === listOfCCUs[0]?.id?.toString('hex')); + expect(foundCCU).toBeUndefined(); + }); }); }); }); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/endpoint.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/endpoint.spec.ts index b918a9d387..78ed7a5540 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/endpoint.spec.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/endpoint.spec.ts @@ -12,132 +12,43 @@ * Removal or modification of this copyright notice is prohibited. */ -import { removeSync } from 'fs-extra'; -import { when } from 'jest-when'; import { - ApplicationConfigForPlugin, - GenesisConfig, testing, cryptography, - apiClient, db, + chain, + codec, + certificateSchema, + SubmitMainchainCrossChainUpdateCommand, + MODULE_NAME_INTEROPERABILITY, + CROSS_CHAIN_COMMAND_NAME_TRANSFER, + BLS_PUBLIC_KEY_LENGTH, + PluginEndpointContext, } from 'lisk-sdk'; -import { ChainConnectorPlugin } from '../../src/chain_connector_plugin'; import * as chainConnectorDB from '../../src/db'; -import { CCMsFromEvents, CCMsFromEventsJSON, LastSentCCMWithHeightJSON } from '../../src/types'; -import { ccmsFromEventsToJSON, getMainchainID } from '../../src/utils'; +import { BlockHeader, CCMWithHeight, LastSentCCM, ValidatorsDataWithHeight } from '../../src/types'; +import { ChainConnectorEndpoint } from '../../src/endpoint'; +import { ADDRESS_LENGTH } from '../../src/constants'; +import { + aggregateCommitToJSON, + ccmsWithHeightToJSON, + validatorsHashPreimagetoJSON, +} from '../../src/utils'; describe('endpoints', () => { - const ownChainID = Buffer.from('10000000', 'hex'); - const appConfigForPlugin: ApplicationConfigForPlugin = { - ...testing.fixtures.defaultConfig, - genesis: { - chainID: ownChainID.toString('hex'), - } as GenesisConfig, - generator: { - keys: { - fromFile: '', - }, - }, - modules: {}, - legacy: { - brackets: [], - sync: false, - }, - }; - - const validators = [ - { - address: cryptography.utils.getRandomBytes(20), - bftWeight: BigInt(2), - blsKey: cryptography.utils.getRandomBytes(20), - }, - ]; - const validatorsJSON = [ - { - address: validators[0].address.toString('hex'), - bftWeight: BigInt(2).toString(), - blsKey: validators[0].blsKey.toString('hex'), - }, - ]; - const validatorsData = { - certificateThreshold: BigInt(70), - validators, - validatorsHash: cryptography.utils.getRandomBytes(20), - }; - const validatorsDataJSON = { - certificateThreshold: validatorsData.certificateThreshold.toString(), - validators: validatorsJSON, - validatorsHash: validatorsData.validatorsHash.toString('hex'), - }; - const aggregateCommit = { - height: 0, - aggregationBits: Buffer.alloc(0), - certificateSignature: Buffer.alloc(0), - }; - const aggregateCommitJSON = { - height: 0, - aggregationBits: Buffer.alloc(0).toString('hex'), - certificateSignature: Buffer.alloc(0).toString('hex'), - }; - const lastSentCCM = { - crossChainCommand: 'transfer', - fee: BigInt(0), - module: 'token', - nonce: BigInt(0), - params: Buffer.alloc(2), - receivingChainID: Buffer.from('04000001', 'hex'), - sendingChainID: Buffer.from('04000000', 'hex'), - status: 1, - }; const defaultPrivateKey = '6c5e2b24ff1cc99da7a49bd28420b93b2a91e2e2a3b0a0ce07676966b707d3c2859bbd02747cf8e26dab592c02155dfddd4a16b0fe83fd7e7ffaec0b5391f3f7'; const defaultPassword = '123'; - const defaultCCUFee = '500000'; - let chainConnectorPlugin: ChainConnectorPlugin; + let connectorDB: chainConnectorDB.ChainConnectorDB; + let inMemoryDB: db.Database; + let endpoint: ChainConnectorEndpoint; + let endpointContext: PluginEndpointContext; beforeEach(async () => { - chainConnectorPlugin = new ChainConnectorPlugin(); - const sendingChainAPIClientMock = { - subscribe: jest.fn(), - invoke: jest.fn(), - }; - - const receivingChainAPIClientMock = { - subscribe: jest.fn(), - invoke: jest.fn(), - }; - - jest - .spyOn(apiClient, 'createIPCClient') - .mockResolvedValue(receivingChainAPIClientMock as never); - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getOwnChainAccount') - .mockResolvedValue({ - chainID: ownChainID.toString('hex'), - }); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getOwnChainAccount') - .mockResolvedValue({ - chainID: getMainchainID(ownChainID).toString('hex'), - }); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChainAccount', { chainID: ownChainID.toString('hex') }) - .mockResolvedValue({ - lastCertificate: { - height: 10, - stateRoot: cryptography.utils.getRandomBytes(32).toString('hex'), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(32).toString('hex'), - }, - name: 'chain1', - status: 1, - }); - jest - .spyOn(chainConnectorDB, 'getDBInstance') - .mockResolvedValue(new db.InMemoryDatabase() as never); - + inMemoryDB = new db.InMemoryDatabase() as any; + connectorDB = new chainConnectorDB.ChainConnectorDB(); + connectorDB['_db'] = inMemoryDB; const encryptedKey = await cryptography.encrypt.encryptMessageWithPassword( Buffer.from(defaultPrivateKey, 'hex'), defaultPassword, @@ -150,182 +61,363 @@ describe('endpoints', () => { }, ); const defaultEncryptedPrivateKey = cryptography.encrypt.stringifyEncryptedMessage(encryptedKey); + endpoint = new ChainConnectorEndpoint(); + endpoint.load(defaultEncryptedPrivateKey, connectorDB); + }); - await chainConnectorPlugin.init({ - config: { - receivingChainIPCPath: '~/.lisk/mainchain', - sendingChainIPCPath: '~/.lisk/sidechain', - ccuFee: defaultCCUFee, - encryptedPrivateKey: defaultEncryptedPrivateKey, - ccuFrequency: 10, - password: defaultPassword, - receivingChainID: getMainchainID(ownChainID).toString('hex'), - }, - appConfig: appConfigForPlugin, - logger: testing.mocks.loggerMock, - }); - (chainConnectorPlugin as any)['_apiClient'] = sendingChainAPIClientMock; - - await chainConnectorPlugin.load(); - await chainConnectorPlugin['_chainConnectorStore'].setAggregateCommits([aggregateCommit]); - await chainConnectorPlugin['_chainConnectorStore'].setValidatorsHashPreimage([validatorsData]); + afterAll(() => { + connectorDB.close(); }); - afterEach(async () => { - (chainConnectorPlugin as any)['_sidechainAPIClient'] = { - disconnect: jest.fn(), - }; - (chainConnectorPlugin as any)['_mainchainAPIClient'] = { - disconnect: jest.fn(), - }; + describe('getSentCCUs', () => { + let listOfCCUs: chain.TransactionAttrs[]; + let listOfCCUsJSON: Record[]; + + beforeEach(() => { + const sampleBlockHeader = testing.createFakeBlockHeader({}); + const certificate = { + aggregationBits: Buffer.alloc(2), + blockID: sampleBlockHeader.id, + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature, + stateRoot: sampleBlockHeader.stateRoot, + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash, + }; - await chainConnectorPlugin['_chainConnectorStore']['_db'].clear(); - }); + const params = { + activeValidatorsUpdate: { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: Buffer.alloc(0), + }, + certificate: codec.encode(certificateSchema, certificate), + certificateThreshold: BigInt(1), + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes: [], + outboxRootWitness: { + bitmap: Buffer.alloc(1), + siblingHashes: [], + }, + }, + sendingChainID: Buffer.from('04000001', 'hex'), + }; - afterAll(() => { - chainConnectorPlugin['_chainConnectorStore']['_db'].close(); + const paramsJSON = { + activeValidatorsUpdate: { + blsKeysUpdate: [], + bftWeightsUpdate: [], + bftWeightsUpdateBitmap: '', + }, + certificate: { + aggregationBits: Buffer.alloc(2).toString('hex'), + blockID: sampleBlockHeader.id.toString('hex'), + height: sampleBlockHeader.height, + signature: sampleBlockHeader.signature.toString('hex'), + stateRoot: sampleBlockHeader.stateRoot?.toString('hex'), + timestamp: sampleBlockHeader.timestamp, + validatorsHash: sampleBlockHeader.validatorsHash?.toString('hex'), + }, + certificateThreshold: '1', + inboxUpdate: { + crossChainMessages: [], + messageWitnessHashes: [], + outboxRootWitness: { + bitmap: '00', + siblingHashes: [], + }, + }, + sendingChainID: '04000001', + }; - removeSync(chainConnectorPlugin['dataPath']); - }); + const ccuOne = testing.createTransaction({ + commandClass: SubmitMainchainCrossChainUpdateCommand as any, + module: MODULE_NAME_INTEROPERABILITY, + params, + chainID: Buffer.from('04000001', 'hex'), + }); + + const ccuTwo = testing.createTransaction({ + commandClass: SubmitMainchainCrossChainUpdateCommand as any, + module: MODULE_NAME_INTEROPERABILITY, + params, + chainID: Buffer.from('04000005', 'hex'), + fee: BigInt(100000), + nonce: BigInt(2), + }); + + listOfCCUs = [ + { ...ccuOne.toObject(), id: ccuOne.id }, + { ...ccuTwo.toObject(), id: ccuTwo.id }, + ].sort((a, b) => Number(BigInt(b.nonce) - BigInt(a.nonce))); + + listOfCCUsJSON = [ + { ...ccuTwo.toJSON(), params: { ...paramsJSON }, id: ccuTwo.id.toString('hex') }, + { ...ccuOne.toJSON(), params: { ...paramsJSON }, id: ccuOne.id.toString('hex') }, + ].sort((a, b) => Number(BigInt(b.nonce) - BigInt(a.nonce))); + }); - describe('getSentCCUs', () => { it('should return sent ccus', async () => { - const response = await chainConnectorPlugin.endpoint.getSentCCUs({} as any); + for (const ccu of listOfCCUs) { + await connectorDB.setCCUTransaction(ccu); + } + + const { list, total } = await endpoint.getSentCCUs(endpointContext); - expect(response).toStrictEqual([]); + expect(list).toEqual(listOfCCUsJSON); + expect(total).toBe(2); }); }); describe('getAggregateCommits', () => { - it('should return aggregate commits', async () => { - const response = await chainConnectorPlugin.endpoint.getAggregateCommits({} as any); + let sampleBlockHeaders: BlockHeader[]; - expect(response).toStrictEqual([aggregateCommitJSON]); + beforeEach(async () => { + sampleBlockHeaders = [10, 11, 12, 13].map(index => + testing + .createFakeBlockHeader({ + height: index, + aggregateCommit: { + aggregationBits: Buffer.alloc(1), + certificateSignature: cryptography.utils.hash(Buffer.alloc(2)), + height: index - 4, + }, + }) + .toObject(), + ); + for (const blockHeader of sampleBlockHeaders) { + await connectorDB.saveToDBOnNewBlock(blockHeader); + } }); - }); - - describe('getValidatorsInfoFromPreimage', () => { - it('should return list of validators info', async () => { - const response = await chainConnectorPlugin.endpoint.getValidatorsInfoFromPreimage({} as any); - expect(response).toStrictEqual([validatorsDataJSON]); + it('should return aggregateCommit between the given heights', async () => { + const fromHeight = 7; + const toHeight = 9; + endpointContext = testing.createTransientModuleEndpointContext({ + params: { from: fromHeight, to: toHeight }, + }); + const aggregateCommits = await endpoint.getAggregateCommits(endpointContext); + + expect(aggregateCommits).toHaveLength(toHeight + 1 - fromHeight); + expect(aggregateCommits.reverse()).toEqual( + sampleBlockHeaders + .map(h => h.aggregateCommit) + .map(a => aggregateCommitToJSON(a)) + .filter(b => b.height >= fromHeight && b.height <= toHeight), + ); }); }); describe('getBlockHeaders', () => { - let blockHeadersObj: any; - let blockHeadersJSON: any; + let sampleBlockHeaders: BlockHeader[]; beforeEach(async () => { - const blockHeaders = new Array(5).fill(0).map(_ => testing.createFakeBlockHeader()); - blockHeadersObj = blockHeaders.map(b => b.toObject()); - blockHeadersJSON = blockHeaders.map(b => b.toJSON()); - await chainConnectorPlugin['_chainConnectorStore'].setBlockHeaders(blockHeadersObj); - }); + sampleBlockHeaders = [1, 2, 3, 4].map(index => + testing.createFakeBlockHeader({ height: index }).toObject(), + ); - it('should return list of block headers', async () => { - const response = await chainConnectorPlugin.endpoint.getBlockHeaders({} as any); + for (const blockHeader of sampleBlockHeaders) { + await connectorDB.saveToDBOnNewBlock(blockHeader); + } + }); - expect(response).toStrictEqual(blockHeadersJSON); + it('should return block headers between the given heights', async () => { + const fromHeight = 2; + const toHeight = 4; + endpointContext = testing.createTransientModuleEndpointContext({ + params: { from: fromHeight, to: toHeight }, + }); + const blockHeaders = await endpoint.getBlockHeaders(endpointContext); + + expect(blockHeaders).toHaveLength(toHeight + 1 - fromHeight); + expect( + sampleBlockHeaders + .map(blockHeader => new chain.BlockHeader(blockHeader).toJSON()) + .filter(a => a.height >= 2) + .reverse(), + ).toEqual(blockHeaders); }); }); describe('getCrossChainMessages', () => { - let ccmsFromEvents: CCMsFromEvents; - let ccmsFromEventsJSON: CCMsFromEventsJSON; + let sampleCrossChainMessages: Record; beforeEach(async () => { - ccmsFromEvents = { - ccms: [ + sampleCrossChainMessages = { + '10': [ { - ...lastSentCCM, + crossChainCommand: 'transfer', + fee: BigInt(1), + module: 'token', + nonce: BigInt(10), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('10000000', 'hex'), + sendingChainID: Buffer.from('10000001', 'hex'), + status: 1, + height: 10, + }, + ], + '11': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(12), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 11, + }, + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(13), + params: Buffer.alloc(1), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 11, + }, + ], + '12': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(12), + params: Buffer.alloc(2), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 12, + }, + ], + '13': [ + { + crossChainCommand: 'transfer', + fee: BigInt(2), + module: 'token', + nonce: BigInt(13), + params: Buffer.alloc(1), + receivingChainID: Buffer.from('01000000', 'hex'), + sendingChainID: Buffer.from('00000001', 'hex'), + status: 1, + height: 13, }, ], - height: 1, - inclusionProof: { - bitmap: Buffer.alloc(0), - siblingHashes: [], - }, - outboxSize: 2, }; - ccmsFromEventsJSON = ccmsFromEventsToJSON(ccmsFromEvents); - await chainConnectorPlugin['_chainConnectorStore'].setCrossChainMessages([ccmsFromEvents]); + await connectorDB.setCCMsByHeight(sampleCrossChainMessages['10'], 10); + await connectorDB.setCCMsByHeight(sampleCrossChainMessages['11'], 11); + await connectorDB.setCCMsByHeight(sampleCrossChainMessages['12'], 12); + await connectorDB.setCCMsByHeight(sampleCrossChainMessages['13'], 13); }); - it('should return list of ccms from events', async () => { - const response = await chainConnectorPlugin.endpoint.getCrossChainMessages({} as any); - - expect(response).toStrictEqual([ccmsFromEventsJSON]); + it('should return all the ccms between then given heights', async () => { + const fromHeight = 10; + const toHeight = 12; + endpointContext = testing.createTransientModuleEndpointContext({ + params: { from: fromHeight, to: toHeight }, + }); + const ccms = await endpoint.getCrossChainMessages(endpointContext); + expect(ccms).toHaveLength( + sampleCrossChainMessages['10'].length + + sampleCrossChainMessages['11'].length + + sampleCrossChainMessages['12'].length, + ); + expect(ccms).toEqual( + ccmsWithHeightToJSON([ + ...sampleCrossChainMessages['10'], + ...sampleCrossChainMessages['11'].reverse(), + ...sampleCrossChainMessages['12'], + ]).reverse(), + ); }); }); describe('getLastSentCCM', () => { - let lastSentCCMJSON: LastSentCCMWithHeightJSON; + let sampleLastSentCCM: LastSentCCM; - beforeEach(async () => { - lastSentCCMJSON = { - ...lastSentCCM, + beforeEach(() => { + sampleLastSentCCM = { + crossChainCommand: CROSS_CHAIN_COMMAND_NAME_TRANSFER, + fee: BigInt(1000), height: 1, - fee: lastSentCCM.fee.toString(), - nonce: lastSentCCM.nonce.toString(), - params: lastSentCCM.params.toString('hex'), - receivingChainID: lastSentCCM.receivingChainID.toString('hex'), - sendingChainID: lastSentCCM.sendingChainID.toString('hex'), + module: 'token', + nonce: BigInt(1), + params: Buffer.alloc(1), + receivingChainID: Buffer.from('04000000', 'hex'), + sendingChainID: Buffer.from('04000001', 'hex'), + status: 1, + outboxSize: 2, }; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...lastSentCCM, - height: lastSentCCMJSON.height, - }); - }); - - it('should return list of ccms from events', async () => { - const response = await chainConnectorPlugin.endpoint.getLastSentCCM({} as any); - - expect(response).toStrictEqual(lastSentCCMJSON); }); - }); - describe('authorize', () => { - it('should reject when invalid params is given', async () => { - await expect(chainConnectorPlugin.endpoint.authorize({ params: {} } as any)).rejects.toThrow( - "must have required property 'password'", + it('should return undefined when there is no record', async () => { + endpointContext = testing.createTransientModuleEndpointContext({}); + await expect(endpoint.getLastSentCCM(endpointContext)).rejects.toThrow( + 'No CCM was sent so far', ); }); - it('should enable when correct password is given', async () => { - await expect( - chainConnectorPlugin.endpoint.authorize({ - params: { enable: true, password: defaultPassword }, - } as any), - ).resolves.toEqual({ - result: 'Successfully enabled the chain connector plugin.', + it('should return lastSentCCM', async () => { + endpointContext = testing.createTransientModuleEndpointContext({}); + + await connectorDB.setLastSentCCM(sampleLastSentCCM); + + await expect(endpoint.getLastSentCCM(endpointContext)).resolves.toEqual({ + ...sampleLastSentCCM, + fee: sampleLastSentCCM.fee.toString(), + nonce: sampleLastSentCCM.nonce.toString(), + params: sampleLastSentCCM.params.toString('hex'), + receivingChainID: sampleLastSentCCM.receivingChainID.toString('hex'), + sendingChainID: sampleLastSentCCM.sendingChainID.toString('hex'), }); }); + }); - it('should not enable when incorrect password is given', async () => { - await expect( - chainConnectorPlugin.endpoint.authorize({ - params: { enable: true, password: 'invalid' }, - } as any), - ).rejects.toThrow('Unsupported state or unable to authenticate data'); - }); + describe('getAllValidatorsData', () => { + let sampleValidatorsData: ValidatorsDataWithHeight[]; - it('should not disable when incorrect password is given', async () => { - await expect( - chainConnectorPlugin.endpoint.authorize({ - params: { enable: false, password: defaultPassword }, - } as any), - ).resolves.toEqual({ - result: 'Successfully disabled the chain connector plugin.', - }); + beforeEach(async () => { + sampleValidatorsData = [20, 21].map(index => ({ + certificateThreshold: BigInt(68), + validators: [ + { + address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), + bftWeight: BigInt(1), + blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), + }, + { + address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), + bftWeight: BigInt(1), + blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), + }, + ], + validatorsHash: cryptography.utils.getRandomBytes(54), + height: index, + })); + + for (const validatorData of sampleValidatorsData) { + await connectorDB.setValidatorsDataByHash( + validatorData.validatorsHash, + validatorData, + validatorData.height, + ); + } }); - it('should disable when incorrect password is given', async () => { - await expect( - chainConnectorPlugin.endpoint.authorize({ - params: { enable: false, password: 'invalid' }, - } as any), - ).rejects.toThrow('Unsupported state or unable to authenticate data'); + it('should return all the validators data present in the db', async () => { + endpointContext = testing.createTransientModuleEndpointContext({}); + const allValidatorsData = await endpoint.getAllValidatorsData(endpointContext); + expect(allValidatorsData).toHaveLength(sampleValidatorsData.length); + expect(allValidatorsData).toEqual( + validatorsHashPreimagetoJSON( + sampleValidatorsData.sort((a, b) => b.validatorsHash.compare(a.validatorsHash)), + ), + ); }); }); }); diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/inbox_update.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/inbox_update.spec.ts index 4e68cead9a..8a62ca2988 100644 --- a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/inbox_update.spec.ts +++ b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/inbox_update.spec.ts @@ -12,39 +12,19 @@ * Removal or modification of this copyright notice is prohibited. */ -import { CCMsg, tree } from 'lisk-sdk'; +import { tree } from 'lisk-sdk'; import { CCU_TOTAL_CCM_SIZE } from '../../src/constants'; -import { CCMsFromEvents } from '../../src/types'; +import { CCMWithHeight } from '../../src/types'; import { calculateMessageWitnesses } from '../../src/inbox_update'; import { getSampleCCM } from '../utils/sampleCCM'; describe('inboxUpdate', () => { - let sampleCCMs: CCMsg[]; - let sampleCCMsFromEvents: CCMsFromEvents[]; + let sampleCCMsFromEvents: CCMWithHeight[]; beforeEach(() => { - sampleCCMs = new Array(4).fill(0).map((_, index) => getSampleCCM(index + 1)); - - sampleCCMsFromEvents = [ - { - ccms: sampleCCMs.slice(0, 1), - height: 60, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [], - }, - outboxSize: 1, - }, - { - ccms: sampleCCMs.slice(2, 3), - height: 64, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(1)], - }, - outboxSize: 2, - }, - ]; + sampleCCMsFromEvents = new Array(4) + .fill(0) + .map((_, index) => ({ ...getSampleCCM(index + 1), height: index + 100 })); }); describe('calculateMessageWitnesses', () => { @@ -72,15 +52,8 @@ describe('inboxUpdate', () => { .mockReturnValue([Buffer.alloc(1)]); const ccmListWithBigSize = [ ...sampleCCMsFromEvents, - { - ccms: [getSampleCCM(5, 8000), getSampleCCM(6, 8000)], - height: 60, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.from('01')], - }, - outboxSize: 2, - }, + { ...getSampleCCM(5, 8000), height: 60 }, + { ...getSampleCCM(6, 8000), height: 60 }, ]; const messageWitnessHashesForCCMs = calculateMessageWitnesses( diff --git a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/plugin.spec.ts b/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/plugin.spec.ts deleted file mode 100644 index 3c592feeae..0000000000 --- a/framework-plugins/lisk-framework-chain-connector-plugin/test/unit/plugin.spec.ts +++ /dev/null @@ -1,1847 +0,0 @@ -/* - * Copyright © 2022 Lisk Foundation - * - * See the LICENSE file at the top-level directory of this distribution - * for licensing information. - * - * Unless otherwise agreed in a custom licensing agreement with the Lisk Foundation, - * no part of this software, including this file, may be copied, modified, - * propagated, or distributed except according to the terms contained in the - * LICENSE file. - * - * Removal or modification of this copyright notice is prohibited. - */ - -import { - cryptography, - testing, - apiClient, - ApplicationConfigForPlugin, - codec, - db, - Block, - AggregateCommit, - chain, - ccmSchema, - ChannelDataJSON, - CCMsg, - certificateSchema, - tree, - CrossChainUpdateTransactionParams, - Certificate, - BFTHeights, - transactions, - ccuParamsSchema, -} from 'lisk-sdk'; -import { when } from 'jest-when'; -import { - CCU_FREQUENCY, - MODULE_NAME_INTEROPERABILITY, - CCM_SEND_SUCCESS, - ADDRESS_LENGTH, - BLS_PUBLIC_KEY_LENGTH, - HASH_LENGTH, - CCM_PROCESSED, - CCU_TOTAL_CCM_SIZE, - EMPTY_BYTES, - COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, -} from '../../src/constants'; -import { getSampleCCM } from '../utils/sampleCCM'; -import * as plugins from '../../src/chain_connector_plugin'; -import * as dbApi from '../../src/db'; -import { - BlockHeader, - CCMsFromEvents, - ChainConnectorPluginConfig, - ValidatorsData, -} from '../../src/types'; -import * as certificateGenerationUtil from '../../src/certificate_generation'; -import * as activeValidatorsUpdateUtil from '../../src/active_validators_update'; -import { getMainchainID } from '../../src/utils'; -import { getSampleCCU } from '../utils/sampleCCU'; - -describe('ChainConnectorPlugin', () => { - const BLS_SIGNATURE_LENGTH = 96; - const ownChainID = Buffer.from('04000000', 'hex'); - const appConfigForPlugin: ApplicationConfigForPlugin = { - ...testing.fixtures.defaultConfig, - genesis: { - chainID: ownChainID.toString('hex'), - } as any, - rpc: { - modes: ['ipc'], - port: 8080, - host: '127.0.0.1', - accessControlAllowOrigin: '*', - }, - }; - - enum CCMProcessedResult { - APPLIED = 0, - FORWARDED = 1, - BOUNCED = 2, - DISCARDED = 3, - } - - const ccmSendSuccessDataSchema = { - $id: '/interoperability/events/ccmSendSuccess', - type: 'object', - required: ['ccm'], - properties: { - ccm: { - fieldNumber: 1, - type: ccmSchema.type, - required: [...ccmSchema.required], - properties: { - ...ccmSchema.properties, - }, - }, - }, - }; - - const ccmProcessedEventSchema = { - $id: '/interoperability/events/ccmProcessed', - type: 'object', - required: ['ccm', 'result', 'code'], - properties: { - ccm: { - fieldNumber: 1, - type: ccmSchema.type, - required: [...ccmSchema.required], - properties: { - ...ccmSchema.properties, - }, - }, - result: { - dataType: 'uint32', - fieldNumber: 2, - }, - code: { - dataType: 'uint32', - fieldNumber: 3, - }, - }, - }; - - const getTestBlock = async (height = 1) => { - return testing.createBlock({ - chainID: Buffer.from('00001111', 'hex'), - privateKey: Buffer.from( - 'd4b1a8a6f91482c40ba1d5c054bd7595cc0230291244fc47869f51c21af657b9e142de105ecd851507f2627e991b54b2b71104b11b6660d0646b9fdbe415fd87', - 'hex', - ), - previousBlockID: cryptography.utils.getRandomBytes(20), - timestamp: Math.floor(Date.now() / 1000), - header: { height }, - }); - }; - - const initChainConnectorPlugin = async ( - chainConnectorPlugin: plugins.ChainConnectorPlugin, - defaultConfig: ChainConnectorPluginConfig & Record, - ) => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - }; - - let chainConnectorPlugin: plugins.ChainConnectorPlugin; - let sendingChainAPIClientMock: apiClient.APIClient; - let receivingChainAPIClientMock: apiClient.APIClient; - const defaultPrivateKey = - '6c5e2b24ff1cc99da7a49bd28420b93b2a91e2e2a3b0a0ce07676966b707d3c2859bbd02747cf8e26dab592c02155dfddd4a16b0fe83fd7e7ffaec0b5391f3f7'; - const defaultPassword = '123'; - const defaultCCUFee = '500000'; - const sampleCCUParams: CrossChainUpdateTransactionParams = { - sendingChainID: Buffer.from('04000001', 'hex'), - activeValidatorsUpdate: { - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: EMPTY_BYTES, - blsKeysUpdate: [], - }, - certificate: EMPTY_BYTES, - certificateThreshold: BigInt(1), - inboxUpdate: { - crossChainMessages: [], - messageWitnessHashes: [], - outboxRootWitness: { - bitmap: EMPTY_BYTES, - siblingHashes: [], - }, - }, - }; - - const getApiClientMock = () => ({ - disconnect: jest.fn(), - invoke: jest.fn(), - subscribe: jest.fn(), - connect: jest.fn(), - node: { getNodeInfo: jest.fn() }, - }); - - const chainConnectorStoreMock = { - setBlockHeaders: jest.fn(), - getBlockHeaders: jest.fn(), - setAggregateCommits: jest.fn(), - getAggregateCommits: jest.fn(), - setCrossChainMessages: jest.fn(), - getCrossChainMessages: jest.fn(), - getValidatorsHashPreimage: jest.fn(), - setValidatorsHashPreimage: jest.fn(), - getLastSentCCM: jest.fn(), - setLastSentCCM: jest.fn(), - close: jest.fn(), - getListOfCCUs: jest.fn().mockResolvedValue([]), - setListOfCCUs: jest.fn(), - }; - - let defaultEncryptedPrivateKey: string; - let defaultConfig: ChainConnectorPluginConfig & Record; - let sampleBFTHeights: BFTHeights; - - beforeEach(async () => { - sampleBFTHeights = { - maxHeightPrevoted: 1, - maxHeightPrecommitted: 1, - maxHeightCertified: 1, - }; - chainConnectorPlugin = new plugins.ChainConnectorPlugin(); - - (chainConnectorStoreMock as any).privateKey = Buffer.from(defaultPrivateKey, 'hex'); - - const encryptedKey = await cryptography.encrypt.encryptMessageWithPassword( - Buffer.from(defaultPrivateKey, 'hex'), - defaultPassword, - { - kdfparams: { - iterations: 1, - memorySize: 256, - parallelism: 1, - }, - }, - ); - defaultEncryptedPrivateKey = cryptography.encrypt.stringifyEncryptedMessage(encryptedKey); - - jest.spyOn(dbApi, 'getDBInstance').mockResolvedValue(new db.InMemoryDatabase() as never); - jest - .spyOn(cryptography.encrypt, 'decryptMessageWithPassword') - .mockResolvedValue(Buffer.from(defaultPrivateKey, 'hex') as never); - (chainConnectorPlugin as any)['_chainConnectorStore'] = chainConnectorStoreMock; - defaultConfig = { - receivingChainIPCPath: '~/.lisk/mainchain', - ccuFee: defaultCCUFee, - encryptedPrivateKey: defaultEncryptedPrivateKey, - ccuFrequency: CCU_FREQUENCY, - password: defaultPassword, - maxCCUSize: CCU_TOTAL_CCM_SIZE, - ccuSaveLimit: 1, - isSaveCCU: false, - registrationHeight: 1, - receivingChainID: getMainchainID(ownChainID).toString('hex'), - }; - - sendingChainAPIClientMock = getApiClientMock() as any; - receivingChainAPIClientMock = getApiClientMock() as any; - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getOwnChainAccount') - .mockResolvedValue({ - chainID: ownChainID.toString('hex'), - }); - - jest.spyOn(sendingChainAPIClientMock.node, 'getNodeInfo').mockResolvedValue({ - syncing: false, - } as never); - - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getOwnChainAccount') - .mockResolvedValue({ - chainID: getMainchainID(ownChainID).toString('hex'), - }); - - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChainAccount', { chainID: ownChainID.toString('hex') }) - .mockResolvedValue({ - lastCertificate: { - height: 10, - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - }, - name: 'chain1', - status: 1, - }); - jest.spyOn(apiClient, 'createWSClient').mockResolvedValue(receivingChainAPIClientMock as never); - jest - .spyOn(apiClient, 'createIPCClient') - .mockResolvedValue(receivingChainAPIClientMock as never); - }); - - describe('init', () => { - it('should assign ccuFrequency properties to default values', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, { - receivingChainIPCPath: '~/.lisk/mainchain', - ccuFee: defaultCCUFee, - encryptedPrivateKey: defaultEncryptedPrivateKey, - password: 'lisk', - receivingChainID: getMainchainID(ownChainID).toString('hex'), - } as never); - expect(chainConnectorPlugin['_ccuFrequency']).toEqual(CCU_FREQUENCY); - }); - - it('should assign ccuFrequency properties to passed config values', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, { - ...defaultConfig, - ccuFrequency: 300000, - receivingChainID: getMainchainID(ownChainID).toString('hex'), - }); - expect(chainConnectorPlugin['_ccuFrequency']).toBe(300000); - }); - }); - - describe('load', () => { - it('should initialize api clients with respective paths as per config', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - - await chainConnectorPlugin.load(); - - expect(chainConnectorPlugin['_receivingChainClient']).toBeUndefined(); - expect(chainConnectorPlugin['_sendingChainClient']).toBe(chainConnectorPlugin['_apiClient']); - }); - - it('should initialize api clients for both sending and receiving chain', async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - - expect(chainConnectorPlugin['_receivingChainClient']).toBeUndefined(); - expect(chainConnectorPlugin['_sendingChainClient']).toBeDefined(); - }); - - it('should call createWSClient when receivingChainWsURL is provided', async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: { - receivingChainWsURL: 'ws://127.0.0.1:8080/rpc', - ccuFee: defaultCCUFee, - encryptedPrivateKey: defaultEncryptedPrivateKey, - password: 'lisk', - receivingChainID: getMainchainID(ownChainID).toString('hex'), - }, - appConfig: appConfigForPlugin, - }); - - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - jest.spyOn(chainConnectorPlugin as any, '_saveDataOnNewBlock').mockResolvedValue({}); - await chainConnectorPlugin['_newBlockHandler']({ - blockHeader: testing - .createFakeBlockHeader({ - generatorAddress: Buffer.from('66687aadf862bd776c8fc18b8e9f8e2008971485', 'hex'), - }) - .toJSON(), - }); - expect(apiClient.createWSClient).toHaveBeenCalled(); - }); - - it('should throw error when receivingChainWsURL and receivingChainIPCPath are undefined', async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: { - ccuFee: defaultCCUFee, - encryptedPrivateKey: defaultEncryptedPrivateKey, - password: 'lisk', - receivingChainID: getMainchainID(ownChainID).toString('hex'), - }, - appConfig: appConfigForPlugin, - }); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - jest.spyOn(chainConnectorPlugin as any, '_saveDataOnNewBlock').mockResolvedValue({}); - jest.spyOn(chainConnectorPlugin as any, '_initializeReceivingChainClient'); - jest.spyOn(testing.mocks.loggerMock, 'error'); - await chainConnectorPlugin['_newBlockHandler']({ - blockHeader: testing - .createFakeBlockHeader({ - generatorAddress: Buffer.from('66687aadf862bd776c8fc18b8e9f8e2008971485', 'hex'), - }) - .toJSON(), - }); - - expect(testing.mocks.loggerMock.error).toHaveBeenCalledWith( - new Error('IPC path and WS url are undefined in the configuration.'), - 'Failed while handling the new block', - ); - }); - - it('should continue even when receivingChainAPIClient is not available', async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - - jest - .spyOn(apiClient, 'createIPCClient') - .mockRejectedValue(new Error('IPC connection timed out.') as never); - - jest.spyOn(testing.mocks.loggerMock, 'error'); - - await chainConnectorPlugin.load(); - - expect(dbApi.getDBInstance).toHaveBeenCalledTimes(1); - expect(chainConnectorPlugin['_chainConnectorPluginDB']).toEqual( - new db.InMemoryDatabase() as never, - ); - - expect(sendingChainAPIClientMock.subscribe).toHaveBeenCalledTimes(2); - }); - - it('should initialize _chainConnectorDB', async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - - expect(dbApi.getDBInstance).toHaveBeenCalledTimes(1); - expect(chainConnectorPlugin['_chainConnectorPluginDB']).toEqual( - new db.InMemoryDatabase() as never, - ); - }); - }); - - describe('_deleteBlockHandler', () => { - beforeEach(async () => { - (chainConnectorPlugin as any)['_chainConnectorStore'] = chainConnectorStoreMock; - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - }); - - it('should delete block header from db corresponding to chain block header', async () => { - const getSomeBlockHeaders = (count = 4) => { - let height = 0; - return new Array(count).fill(0).map(() => { - height += 1; - const { id, ...block } = testing.createFakeBlockHeader({ height }).toObject(); - return block; - }); - }; - - const someBlockHeaders = getSomeBlockHeaders(); - expect(someBlockHeaders).toHaveLength(4); - - // let's first save some data to db - await chainConnectorPlugin['_chainConnectorStore'].setBlockHeaders(someBlockHeaders); - await expect( - chainConnectorPlugin['_chainConnectorStore'].getBlockHeaders(), - ).resolves.toHaveLength(4); - - // call handler with expected Block having one of stored block headers height - const block = await getTestBlock(); - expect(block.header.height).toEqual(someBlockHeaders[0].height); - - await (chainConnectorPlugin as any)['_deleteBlockHandler']({ - blockHeader: block.header.toJSON(), - }); - - // here, we assume that block with height 1 was removed from db - await expect( - chainConnectorPlugin['_chainConnectorStore'].getBlockHeaders(), - ).resolves.toHaveLength(3); - }); - - it('should delete aggregateCommits from db corresponding to chain block header', async () => { - const getSomeAggregateCommits = (count = 4) => { - let height = 0; - return new Array(count).fill(0).map(() => { - height += 1; - const aggregateCommit: AggregateCommit = { - height, - aggregationBits: Buffer.from('00', 'hex'), - certificateSignature: Buffer.alloc(0), - }; - return aggregateCommit; - }); - }; - - const someAggregateCommits = getSomeAggregateCommits(); - expect(someAggregateCommits[0].height).toBe(1); - expect(someAggregateCommits[3].height).toBe(4); - - // let's first save some data to db - await chainConnectorPlugin['_chainConnectorStore'].setAggregateCommits(someAggregateCommits); - await expect( - chainConnectorPlugin['_chainConnectorStore'].getAggregateCommits(), - ).resolves.toHaveLength(4); - - // call handler with expected Block having one of stored block headers height - const block = await getTestBlock(); - // non-empty aggregate commit with height 1 should be deleted - block.header.aggregateCommit.height = 1; - block.header.aggregateCommit.aggregationBits = Buffer.alloc(1); - expect(block.header.height).toBe(1); - expect(block.header.aggregateCommit.height).toBe(1); - - await (chainConnectorPlugin as any)['_deleteBlockHandler']({ - blockHeader: block.header.toJSON(), - }); - - // here, we assume that block with height 1 was removed from db - await expect( - chainConnectorPlugin['_chainConnectorStore'].getAggregateCommits(), - ).resolves.toHaveLength(3); - }); - - it('should delete ValidatorsHashPreimage from db corresponding to chain block header', async () => { - const getSomeValidatorsHashPreimage = (count = 4, block: Block): ValidatorsData[] => { - let validatorsHash = block.header.validatorsHash as Buffer; - - let i = -1; - return new Array(count).fill(0).map(() => { - i += 1; - if (i > 0) { - validatorsHash = cryptography.utils.getRandomBytes(54); - } - return { - certificateThreshold: BigInt(68), - validators: [ - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(1), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(1), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - ], - validatorsHash, - }; - }); - }; - - const newBlock = await getTestBlock(); - - const someValidatorsHashPreimage = getSomeValidatorsHashPreimage(4, newBlock); - const blocks = await Promise.all( - someValidatorsHashPreimage.slice(1).map(async (vHash, index) => { - const block = await getTestBlock(index + 2); - block.header.validatorsHash = vHash.validatorsHash; - - return block; - }), - ); - await chainConnectorPlugin['_chainConnectorStore'].setBlockHeaders( - blocks.map(b => b.header.toObject()), - ); - - await chainConnectorPlugin['_chainConnectorStore'].setValidatorsHashPreimage( - someValidatorsHashPreimage, - ); - await expect( - chainConnectorPlugin['_chainConnectorStore'].getValidatorsHashPreimage(), - ).resolves.toHaveLength(4); - - expect(newBlock.header.validatorsHash as Buffer).toEqual( - someValidatorsHashPreimage[0].validatorsHash, - ); - - await (chainConnectorPlugin as any)['_deleteBlockHandler']({ - blockHeader: newBlock.header.toJSON(), - }); - - await expect( - chainConnectorPlugin['_chainConnectorStore'].getValidatorsHashPreimage(), - ).resolves.toHaveLength(3); - }); - }); - - describe('_newBlockHandler', () => { - let block: Block; - let sampleNextCertificate: Certificate; - - beforeEach(async () => { - block = await testing.createBlock({ - chainID: Buffer.from('00001111', 'hex'), - privateKey: Buffer.from( - 'd4b1a8a6f91482c40ba1d5c054bd7595cc0230291244fc47869f51c21af657b9e142de105ecd851507f2627e991b54b2b71104b11b6660d0646b9fdbe415fd87', - 'hex', - ), - previousBlockID: cryptography.utils.getRandomBytes(20), - timestamp: Math.floor(Date.now() / 1000), - header: { - height: 20, - }, - }); - - sampleNextCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: block.header.id, - height: block.header.height, - signature: block.header.signature, - stateRoot: block.header.stateRoot as Buffer, - timestamp: block.header.timestamp, - validatorsHash: block.header.validatorsHash as Buffer, - }; - - chainConnectorPlugin['_ownChainID'] = ownChainID; - chainConnectorPlugin['_sendingChainClient'] = sendingChainAPIClientMock; - chainConnectorPlugin['_receivingChainClient'] = receivingChainAPIClientMock; - - const computedCCUParamsMock = jest.fn(); - chainConnectorPlugin['_computeCCUParams'] = computedCCUParamsMock; - computedCCUParamsMock.mockResolvedValue({ - ccuParams: sampleCCUParams, - lastCCMToBeSent: { - ...getSampleCCM(1), - height: 1, - }, - }); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChainAccount', { chainID: ownChainID }) - .mockResolvedValue({ - lastCertificate: { - height: 10, - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - }, - name: 'chain1', - status: 1, - }); - (chainConnectorPlugin as any)['_createCCU'] = jest.fn(); - (chainConnectorPlugin as any)['_cleanup'] = jest.fn(); - - when(sendingChainAPIClientMock.invoke) - .calledWith('consensus_getBFTParameters', { height: block.header.height }) - .mockResolvedValue({ - prevoteThreshold: '2', - precommitThreshold: '2', - certificateThreshold: '3', - validators: [ - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH).toString('hex'), - bftWeight: '2', - generatorKey: cryptography.utils - .getRandomBytes(BLS_PUBLIC_KEY_LENGTH) - .toString('hex'), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH).toString('hex'), - }, - ], - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - }); - when(sendingChainAPIClientMock.invoke) - .calledWith('auth_getAuthAccount', { address: expect.any(String) }) - .mockResolvedValue({ - nonce: '2', - }); - - when(sendingChainAPIClientMock.invoke) - .calledWith('consensus_getBFTHeights') - .mockResolvedValue(sampleBFTHeights); - when(sendingChainAPIClientMock.invoke).calledWith('system_getNodeInfo').mockResolvedValue({ - chainID: '10000000', - }); - jest.spyOn(chainConnectorPlugin as any, '_submitCCU').mockResolvedValue({}); - }); - - it('should invoke "consensus_getBFTParameters" on _sendingChainClient', async () => { - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(sampleNextCertificate); - - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - - const saveDataOnNewBlockMock = jest.fn(); - chainConnectorPlugin['_saveDataOnNewBlock'] = saveDataOnNewBlockMock; - saveDataOnNewBlockMock.mockResolvedValue({ - aggregateCommits: [], - blockHeaders: [], - validatorsHashPreimage: [], - crossChainMessages: [], - }); - await chainConnectorPlugin['_newBlockHandler']({ - blockHeader: block.header.toJSON(), - }); - - // For chain_newBlock and chain_deleteBlock - expect(sendingChainAPIClientMock.subscribe).toHaveBeenCalledTimes(2); - expect(chainConnectorPlugin['_submitCCU']).toHaveBeenCalled(); - }); - - it('should not computeCCUParams if node is syncing', async () => { - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(sampleNextCertificate); - - jest.spyOn(testing.mocks.loggerMock, 'debug'); - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - - const saveDataOnNewBlockMock = jest.fn(); - chainConnectorPlugin['_saveDataOnNewBlock'] = saveDataOnNewBlockMock; - saveDataOnNewBlockMock.mockResolvedValue({ - aggregateCommits: [], - blockHeaders: [], - validatorsHashPreimage: [], - crossChainMessages: [], - }); - jest.spyOn(sendingChainAPIClientMock.node, 'getNodeInfo').mockResolvedValue({ - syncing: true, - } as never); - await chainConnectorPlugin['_newBlockHandler']({ - blockHeader: block.header.toJSON(), - }); - - const numOfBlocksSinceLastCertificate = - block.header.height - chainConnectorPlugin['_lastCertificate'].height; - expect(chainConnectorPlugin['logger'].debug).toHaveBeenCalledWith( - { - syncing: true, - ccuFrequency: chainConnectorPlugin['_ccuFrequency'], - nextPossibleCCUHeight: - chainConnectorPlugin['_ccuFrequency'] - numOfBlocksSinceLastCertificate, - }, - 'No attempt to create CCU either due to ccuFrequency or the node is syncing', - ); - // For chain_newBlock and chain_deleteBlock - expect(sendingChainAPIClientMock.subscribe).toHaveBeenCalledTimes(2); - expect(chainConnectorPlugin['_submitCCU']).not.toHaveBeenCalled(); - }); - - it('should invoke "chain_getEvents" on _sendingChainClient', async () => { - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(sampleNextCertificate); - const newBlockHeaderHeight = 11; - const blockHeaderAtLastCertifiedHeight = { - ...testing - .createFakeBlockHeader({ - height: newBlockHeaderHeight - 1, - }) - .toObject(), - generatorAddress: Buffer.from('66687aadf862bd776c8fc18b8e9f8e2008971485'), - }; - const newBlockHeaderJSON = { - ...testing - .createFakeBlockHeader({ - height: newBlockHeaderHeight, - }) - .toJSON(), - generatorAddress: 'lskoaknq582o6fw7sp82bm2hnj7pzp47mpmbmux2g', - }; - const blockHeaders = [ - blockHeaderAtLastCertifiedHeight, - chain.BlockHeader.fromJSON(newBlockHeaderJSON).toObject(), - ]; - when(sendingChainAPIClientMock.invoke) - .calledWith('consensus_getBFTParametersActiveValidators', { height: newBlockHeaderHeight }) - .mockResolvedValue({ - prevoteThreshold: '2', - precommitThreshold: '2', - certificateThreshold: '3', - validators: [ - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH).toString('hex'), - bftWeight: '2', - generatorKey: cryptography.utils - .getRandomBytes(BLS_PUBLIC_KEY_LENGTH) - .toString('hex'), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH).toString('hex'), - }, - ], - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - }); - - const ccmSendSuccessEvent = { - index: 1, - module: MODULE_NAME_INTEROPERABILITY, - topics: [cryptography.utils.getRandomBytes(10).toString('hex')], - name: CCM_SEND_SUCCESS, - height: newBlockHeaderHeight, - data: codec.encode(ccmSendSuccessDataSchema, { ccm: getSampleCCM(1) }).toString('hex'), - }; - - const ccmProcessedEvent = { - index: 4, - module: MODULE_NAME_INTEROPERABILITY, - topics: [cryptography.utils.getRandomBytes(10).toString('hex')], - name: CCM_PROCESSED, - height: newBlockHeaderHeight, - data: codec - .encode(ccmProcessedEventSchema, { - ccm: getSampleCCM(2), - result: CCMProcessedResult.FORWARDED, - code: 1, - }) - .toString('hex'), - }; - - const eventsJSON = [ccmSendSuccessEvent, ccmProcessedEvent]; - - when(sendingChainAPIClientMock.invoke) - .calledWith('chain_getEvents', { height: newBlockHeaderHeight }) - .mockResolvedValue(eventsJSON); - - when(sendingChainAPIClientMock.invoke) - .calledWith('system_getMetadata') - .mockResolvedValue({ - modules: [ - { - name: MODULE_NAME_INTEROPERABILITY, - stores: [ - { - key: '03ed0d25f0ba', - data: { - $id: '/modules/interoperability/outbox', - }, - }, - ], - events: [ - { - name: CCM_SEND_SUCCESS, - data: ccmSendSuccessDataSchema, - }, - { - name: CCM_PROCESSED, - data: ccmProcessedEventSchema, - }, - ], - }, - ], - }); - - const sampleProof = { - proof: { - siblingHashes: [Buffer.alloc(0)], - queries: [ - { - key: Buffer.alloc(0), - value: Buffer.alloc(0), - bitmap: Buffer.alloc(0), - }, - ], - }, - }; - when(sendingChainAPIClientMock.invoke) - .calledWith('state_prove', { - queryKeys: [ - Buffer.concat([ - Buffer.from('03ed0d25f0ba', 'hex'), - cryptography.utils.hash(ownChainID), - ]).toString('hex'), - ], - }) - .mockResolvedValue(sampleProof); - - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - await chainConnectorPlugin.load(); - await chainConnectorPlugin['_chainConnectorStore'].setBlockHeaders([ - blockHeaderAtLastCertifiedHeight, - ]); - await chainConnectorPlugin['_chainConnectorStore'].setAggregateCommits( - blockHeaders.map(b => b.aggregateCommit), - ); - await (chainConnectorPlugin as any)['_newBlockHandler']({ - blockHeader: newBlockHeaderJSON, - }); - - /** - * Two event subscriptions on sendingChainAPIClient - * 1. chain_newBlock - * 2. chain_deleteBlock - */ - expect(sendingChainAPIClientMock.subscribe).toHaveBeenCalledTimes(2); - /** - * Total 5 calls to below RPCs through sendingChainAPIClient - * 1. chain_getEvents - * 2. system_getMetadata - * 3. state_prove - * 4. consensus_getBFTParameters - * 5. consensus_getBFTHeights - */ - expect(sendingChainAPIClientMock.invoke).toHaveBeenCalledTimes(5); - expect(sendingChainAPIClientMock.node.getNodeInfo).toHaveBeenCalledTimes(1); - /** - * Two calls to below RPC through receivingChainAPIClient - * 1. interoperability_getChainAccount: in load() function - */ - expect(receivingChainAPIClientMock.invoke).toHaveBeenCalledTimes(1); - - const savedCCMs = await chainConnectorPlugin['_chainConnectorStore'].getCrossChainMessages(); - - expect(savedCCMs).toEqual([ - { - ccms: [{ ...getSampleCCM(1) }, { ...getSampleCCM(2) }], - height: 11, - inclusionProof: { - bitmap: sampleProof.proof.queries[0].bitmap, - siblingHashes: sampleProof.proof.siblingHashes, - }, - outboxSize: 0, - }, - ]); - - expect((chainConnectorPlugin as any)['_submitCCU']).toHaveBeenCalled(); - }); - }); - - describe('unload', () => { - it.todo('should unload plugin'); - }); - - describe('Cleanup Functions', () => { - let blockHeader1: BlockHeader; - let blockHeader2: BlockHeader; - let sampleCCUs: chain.TransactionAttrs[]; - - beforeEach(async () => { - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getOwnChainAccount') - .mockResolvedValue({ - chainID: ownChainID.toString('hex'), - }); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChainAccount', { chainID: ownChainID }) - .mockResolvedValue({ - lastCertificate: { - height: 10, - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - }, - name: 'chain1', - status: 1, - }); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - - await chainConnectorPlugin.load(); - (chainConnectorPlugin as any)['_chainConnectorStore'] = chainConnectorStoreMock; - - chainConnectorPlugin['_lastCertificate'] = { - height: 6, - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }; - chainConnectorPlugin['_heightToDeleteIndex'].set(0, { - inboxSize: 10, - lastCertificateHeight: 10, - }); - chainConnectorStoreMock.getCrossChainMessages.mockResolvedValue([ - getSampleCCM(1), - getSampleCCM(2), - ] as never); - jest - .spyOn(chainConnectorPlugin['_chainConnectorStore'], 'getAggregateCommits') - .mockResolvedValue([ - { - height: 5, - }, - ] as never); - jest - .spyOn(chainConnectorPlugin['_chainConnectorStore'], 'getValidatorsHashPreimage') - .mockResolvedValue([ - { - certificateThreshold: 5, - validatorsHash: cryptography.utils.getRandomBytes(54), - }, - ] as never); - blockHeader1 = testing.createFakeBlockHeader({ height: 5 }).toObject(); - blockHeader2 = testing.createFakeBlockHeader({ height: 6 }).toObject(); - chainConnectorStoreMock.getBlockHeaders.mockResolvedValue([ - blockHeader1, - blockHeader2, - ] as never); - sampleCCUs = [ - getSampleCCU({ nonce: BigInt(1) }), - getSampleCCU({ nonce: BigInt(2) }), - getSampleCCU({ nonce: BigInt(2) }), - ]; - chainConnectorStoreMock.getListOfCCUs.mockResolvedValue(sampleCCUs as never); - }); - - it('should delete block headers with height less than finalized lastCertifiedHeight', async () => { - await chainConnectorPlugin['_cleanup'](); - - expect(chainConnectorStoreMock.getBlockHeaders).toHaveBeenCalledTimes(1); - - expect(chainConnectorStoreMock.setBlockHeaders).toHaveBeenCalledWith([]); - }); - - it('should delete aggregate commits with height less than _lastCertifiedHeight', async () => { - await chainConnectorPlugin['_cleanup'](); - - expect( - chainConnectorPlugin['_chainConnectorStore'].getAggregateCommits, - ).toHaveBeenCalledTimes(1); - - expect(chainConnectorPlugin['_chainConnectorStore'].setAggregateCommits).toHaveBeenCalledWith( - [], - ); - }); - - it('should delete validatorsHashPreimage with certificate threshold less than _lastCertifiedHeight', async () => { - await chainConnectorPlugin['_cleanup'](); - - expect( - chainConnectorPlugin['_chainConnectorStore'].getValidatorsHashPreimage, - ).toHaveBeenCalledTimes(1); - - expect( - chainConnectorPlugin['_chainConnectorStore'].setValidatorsHashPreimage, - ).toHaveBeenCalledWith([]); - }); - - it('should delete sentCCUs based on config ccuSaveLimit', async () => { - await chainConnectorPlugin['_cleanup'](); - - expect(chainConnectorPlugin['_chainConnectorStore'].getListOfCCUs).toHaveBeenCalledTimes(1); - - expect(chainConnectorPlugin['_chainConnectorStore'].setListOfCCUs).toHaveBeenCalledWith([ - sampleCCUs[2], - ]); - }); - }); - - describe('_getCcuFee', () => { - const transactionTemplate = { - module: MODULE_NAME_INTEROPERABILITY, - command: COMMAND_NAME_SUBMIT_MAINCHAIN_CCU, - nonce: BigInt(0), - senderPublicKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - params: sampleCCUParams, - signatures: [], - }; - - beforeEach(() => { - chainConnectorPlugin['_receivingChainClient'] = receivingChainAPIClientMock; - }); - - describe('userAddress does not exist in receivingChain', () => { - const initializationFees = { - userAccount: BigInt('1000000000'), - escrowAccount: BigInt('2000000000'), - }; - - beforeEach(() => { - when(receivingChainAPIClientMock.invoke) - .calledWith('token_hasUserAccount', expect.anything()) - .mockResolvedValue({ - exists: false, - }); - when(receivingChainAPIClientMock.invoke) - .calledWith('token_getInitializationFees') - .mockResolvedValue(initializationFees); - }); - - it('should return config.ccuFee + additionalFee when config.ccuFee exists', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - await expect(chainConnectorPlugin['_getCcuFee'](transactionTemplate)).resolves.toBe( - BigInt(defaultCCUFee) + initializationFees.userAccount, - ); - }); - - it('should calculate by `computeMinFee` + additionalFee when config.ccuFee does not exist', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, { - ...defaultConfig, - ccuFee: '0', - }); - - await expect(chainConnectorPlugin['_getCcuFee'](transactionTemplate)).resolves.toBe( - transactions.computeMinFee(transactionTemplate, ccuParamsSchema, { - additionalFee: initializationFees.userAccount, - }), - ); - }); - }); - - describe('userAddress exists in receivingChain', () => { - beforeEach(() => { - when(receivingChainAPIClientMock.invoke) - .calledWith('token_hasUserAccount', expect.anything()) - .mockResolvedValue({ - exists: true, - }); - }); - - it('should return config.ccuFee when config.ccuFee exists', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - await expect(chainConnectorPlugin['_getCcuFee'](transactionTemplate)).resolves.toBe( - BigInt(defaultCCUFee), - ); - }); - - it('should calculate by `computeMinFee` when config.ccuFee does not exist', async () => { - await initChainConnectorPlugin(chainConnectorPlugin, { - ...defaultConfig, - ccuFee: '0', - }); - - await expect(chainConnectorPlugin['_getCcuFee'](transactionTemplate)).resolves.toBe( - transactions.computeMinFee(transactionTemplate, ccuParamsSchema), - ); - }); - }); - }); - describe('_computeCCUParams', () => { - let sampleCCMsWithEvents: CCMsFromEvents[]; - let sampleBlockHeaders: BlockHeader[]; - let sampleAggregateCommits: AggregateCommit[]; - let sampleValidatorsHashPreimage: ValidatorsData[]; - let sampleChannelDataJSON: ChannelDataJSON; - - beforeEach(async () => { - sampleBlockHeaders = new Array(10).fill(0).map((_, index) => { - // Aggregate commits at height 3, 6, 9 - if ((index + 1) % 3 === 0) { - return testing - .createFakeBlockHeader({ - height: index + 1, - aggregateCommit: { - aggregationBits: Buffer.alloc(2), - certificateSignature: cryptography.utils.getRandomBytes(54), - height: index - 2, - }, - }) - .toObject(); - } - - // Validators change at height 2, 4, 6, 8, 10 - if ((index + 1) % 2 === 0) { - return testing - .createFakeBlockHeader({ - height: index + 1, - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }) - .toObject(); - } - - return testing.createFakeBlockHeader({ height: index + 1 }).toObject(); - }); - sampleCCMsWithEvents = sampleBlockHeaders.map(b => ({ - ccms: [getSampleCCM(b.height)], - height: b.height, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(1)], - }, - outboxSize: 2, - })); - sampleAggregateCommits = sampleBlockHeaders - .filter(b => !b.aggregateCommit.certificateSignature.equals(Buffer.alloc(0))) - .map(b => b.aggregateCommit); - // Validators change at height 2, 4, 6, 8, 10 - sampleValidatorsHashPreimage = sampleBlockHeaders - .filter(b => b.height % 2 === 0) - .map(b => ({ - certificateThreshold: BigInt(78), - validators: [ - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(40), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(40), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - ], - validatorsHash: b.validatorsHash, - })); - sampleChannelDataJSON = { - inbox: { - appendPath: [], - root: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - size: 2, - }, - outbox: { - appendPath: [], - root: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - size: 2, - }, - partnerChainOutboxRoot: cryptography.utils.getRandomBytes(HASH_LENGTH).toString('hex'), - messageFeeTokenID: '04000010', - minReturnFeePerByte: '1000', - }; - // Save all data to the chainConnectorStore - await chainConnectorPlugin.init({ - logger: testing.mocks.loggerMock, - config: defaultConfig, - appConfig: appConfigForPlugin, - }); - chainConnectorPlugin['_apiClient'] = sendingChainAPIClientMock; - - await chainConnectorPlugin.load(); - chainConnectorPlugin['_receivingChainClient'] = receivingChainAPIClientMock; - // Set all the sample data - await chainConnectorPlugin['_chainConnectorStore'].setBlockHeaders(sampleBlockHeaders); - await chainConnectorPlugin['_chainConnectorStore'].setAggregateCommits( - sampleAggregateCommits, - ); - await chainConnectorPlugin['_chainConnectorStore'].setValidatorsHashPreimage( - sampleValidatorsHashPreimage, - ); - await chainConnectorPlugin['_chainConnectorStore'].setCrossChainMessages( - sampleCCMsWithEvents, - ); - }); - - describe('CCU params calculation when no new ceritifcate and last certificate height == 0', () => { - beforeEach(() => { - jest - .spyOn(chainConnectorPlugin, '_findNextCertificate' as never) - .mockResolvedValue(undefined as never); - chainConnectorPlugin['_lastCertificate'] = { - height: 0, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: cryptography.utils.hash(cryptography.utils.getRandomBytes(4)), - }; - }); - - it('should exit function without CCU params ', async () => { - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - sampleValidatorsHashPreimage, - sampleCCMsWithEvents, - ); - - expect(result).toBeUndefined(); - }); - }); - - describe('CCU params calculation when last certificate', () => { - beforeEach(() => { - when(sendingChainAPIClientMock.invoke) - .calledWith('consensus_getBFTHeights') - .mockResolvedValue(sampleBFTHeights); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_ownChainID'].toString('hex'), - }) - .mockResolvedValue(sampleChannelDataJSON); - - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 3 }, - }); - - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(undefined); - }); - - it('should exit function without CCU params computation when no pending CCMs found after last ccm', async () => { - jest.spyOn(chainConnectorPlugin['logger'], 'info'); - - const validatorsHashAtLastCertificate = sampleBlockHeaders.find(b => b.height === 8); - chainConnectorPlugin['_lastCertificate'] = { - height: 10, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: validatorsHashAtLastCertificate?.validatorsHash as Buffer, - }; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(12), - height: 11, - }); - - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - sampleValidatorsHashPreimage, - sampleCCMsWithEvents, - ); - - expect(result).toBeUndefined(); - - expect(chainConnectorPlugin['logger'].info).toHaveBeenCalledWith( - 'CCU cant be created as there are no pending CCMs for the last certificate.', - ); - }); - - it('should throw error when no validatorsData found for the last certificate', async () => { - jest.spyOn(chainConnectorPlugin['logger'], 'info'); - - chainConnectorPlugin['_lastCertificate'] = { - height: 11, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: cryptography.utils.hash(cryptography.utils.getRandomBytes(4)), - }; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(9), - height: 8, - }); - - await expect( - chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - sampleValidatorsHashPreimage, - sampleCCMsWithEvents, - ), - ).rejects.toThrow('No validatorsData found for the lastCertificate'); - }); - - it('should successfully create CCU with messageWitness for pending ccms', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 4 }, - }); - jest.spyOn(chainConnectorPlugin['logger'], 'info'); - - const validatorsHashAtLastCertificate = sampleBlockHeaders.find(b => b.height === 8); - chainConnectorPlugin['_lastCertificate'] = { - height: 8, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: validatorsHashAtLastCertificate?.validatorsHash as Buffer, - }; - const lastSentCCMsFromEvents = sampleCCMsWithEvents[5]; - const expectedCCMsToBeSent = sampleCCMsWithEvents - .slice(5, 7) - .reduce((ccms: CCMsg[], record: CCMsFromEvents) => { - for (const ccm of record.ccms) { - ccms.push(ccm); - } - - return ccms; - }, []) - .map(ccm => codec.encode(ccmSchema, ccm)); - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...lastSentCCMsFromEvents.ccms[0], - height: lastSentCCMsFromEvents.height, - }); - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - sampleValidatorsHashPreimage, - sampleCCMsWithEvents, - ); - - expect(result).toBeDefined(); - expect(result?.ccuParams).toBeDefined(); - expect((result?.ccuParams as any).inboxUpdate.outboxRootWitness).toEqual({ - bitmap: EMPTY_BYTES, - siblingHashes: [], - }); - expect((result?.ccuParams as any).activeValidatorsUpdate).toEqual({ - blsKeysUpdate: [], - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: EMPTY_BYTES, - }); - expect((result?.ccuParams as any).certificate).toEqual(EMPTY_BYTES); - - expect((result?.ccuParams as any).inboxUpdate.messageWitnessHashes).toEqual([]); - expect((result?.ccuParams as any).inboxUpdate.crossChainMessages).toHaveLength( - expectedCCMsToBeSent.length, - ); - }); - }); - - describe('CCU params calculation for new certificate', () => { - let blockHeaderAtCertificateHeight: BlockHeader; - let sampleValidators: any; - beforeEach(() => { - sampleValidators = [ - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(40), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - { - address: cryptography.utils.getRandomBytes(ADDRESS_LENGTH), - bftWeight: BigInt(40), - blsKey: cryptography.utils.getRandomBytes(BLS_PUBLIC_KEY_LENGTH), - }, - ]; - // eslint-disable-next-line prefer-destructuring - blockHeaderAtCertificateHeight = sampleBlockHeaders[8]; - when(sendingChainAPIClientMock.invoke) - .calledWith('consensus_getBFTHeights') - .mockResolvedValue(sampleBFTHeights); - when(receivingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_ownChainID'].toString('hex'), - }) - .mockResolvedValue(sampleChannelDataJSON); - }); - - it('should throw error when no validators data is found for the new certificate height', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 4 }, - }); - const newCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: blockHeaderAtCertificateHeight.id as Buffer, - height: blockHeaderAtCertificateHeight.height, - signature: cryptography.utils.getRandomBytes(BLS_SIGNATURE_LENGTH), - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: blockHeaderAtCertificateHeight.timestamp, - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }; - - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(newCertificate); - chainConnectorPlugin['_lastCertificate'] = { - height: 4, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - await expect( - chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - sampleValidatorsHashPreimage, - sampleCCMsWithEvents, - ), - ).rejects.toThrow('No validators data found for the certificate height.'); - }); - - it('should return empty activeValidatorsUpdate when (lastCertificate.validatorsHash === newCertificate.validatorsHash)', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 2 }, - }); - const newCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: blockHeaderAtCertificateHeight.id as Buffer, - height: blockHeaderAtCertificateHeight.height, - signature: cryptography.utils.getRandomBytes(BLS_SIGNATURE_LENGTH), - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: blockHeaderAtCertificateHeight.timestamp, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(newCertificate); - chainConnectorPlugin['_lastCertificate'] = { - height: 4, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(4), - height: 4, - }); - const validatorsData = [ - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }, - ]; - - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - validatorsData, - sampleCCMsWithEvents, - ); - expect(result?.ccuParams.activeValidatorsUpdate).toEqual({ - blsKeysUpdate: [], - bftWeightsUpdate: [], - bftWeightsUpdateBitmap: Buffer.from([]), - }); - expect(result?.ccuParams.certificateThreshold).toEqual( - validatorsData[0].certificateThreshold, - ); - expect(result?.ccuParams.certificate).toEqual( - codec.encode(certificateSchema, newCertificate), - ); - expect(result?.ccuParams.inboxUpdate.crossChainMessages).toEqual([]); - expect(result?.ccuParams.inboxUpdate.messageWitnessHashes).toEqual([]); - expect(result?.ccuParams.inboxUpdate.outboxRootWitness).toEqual({ - bitmap: Buffer.alloc(0), - siblingHashes: [], - }); - }); - - it('should return non-empty activeValidatorsUpdate when (lastCertificate.validatorsHash !== newCertificate.validatorsHash)', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 4 }, - }); - const newCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: blockHeaderAtCertificateHeight.id as Buffer, - height: blockHeaderAtCertificateHeight.height, - signature: cryptography.utils.getRandomBytes(BLS_SIGNATURE_LENGTH), - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: blockHeaderAtCertificateHeight.timestamp, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(newCertificate); - const validatorsUpdateResult = { - activeValidatorsUpdate: { - blsKeysUpdate: [cryptography.utils.getRandomBytes(54)], - bftWeightsUpdate: [BigInt(1)], - bftWeightsUpdateBitmap: Buffer.alloc(1), - }, - certificateThreshold: BigInt(79), - }; - jest - .spyOn(activeValidatorsUpdateUtil, 'calculateActiveValidatorsUpdate') - .mockReturnValue(validatorsUpdateResult); - const lastCertificate = { - height: 4, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }; - chainConnectorPlugin['_lastCertificate'] = lastCertificate; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(4), - height: 4, - }); - const validatorsData = [ - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }, - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: lastCertificate.validatorsHash, - }, - ]; - - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - validatorsData, - sampleCCMsWithEvents, - ); - - expect(result?.ccuParams.activeValidatorsUpdate).toEqual( - validatorsUpdateResult.activeValidatorsUpdate, - ); - expect(result?.ccuParams.certificateThreshold).toEqual( - validatorsUpdateResult.certificateThreshold, - ); - expect(result?.ccuParams.certificate).toEqual( - codec.encode(certificateSchema, newCertificate), - ); - - expect(result?.ccuParams.inboxUpdate.crossChainMessages.length).toEqual( - sampleCCMsWithEvents.slice(4, 9).length, - ); - expect(result?.ccuParams.inboxUpdate.messageWitnessHashes).toEqual([]); - expect(result?.ccuParams.inboxUpdate.outboxRootWitness).toEqual( - sampleCCMsWithEvents[8].inclusionProof, - ); - }); - - it('should return serialized ccms and message witnesses when pending ccms', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 4 }, - }); - - const messageWitnessHashes = [cryptography.utils.getRandomBytes(HASH_LENGTH)]; - jest - .spyOn(tree.regularMerkleTree, 'calculateRightWitness') - .mockReturnValue(messageWitnessHashes as never); - - const newCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: blockHeaderAtCertificateHeight.id as Buffer, - height: blockHeaderAtCertificateHeight.height, - signature: cryptography.utils.getRandomBytes(BLS_SIGNATURE_LENGTH), - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: blockHeaderAtCertificateHeight.timestamp, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(newCertificate); - const validatorsUpdateResult = { - activeValidatorsUpdate: { - blsKeysUpdate: [cryptography.utils.getRandomBytes(54)], - bftWeightsUpdate: [BigInt(1)], - bftWeightsUpdateBitmap: Buffer.alloc(1), - }, - certificateThreshold: BigInt(79), - }; - jest - .spyOn(activeValidatorsUpdateUtil, 'calculateActiveValidatorsUpdate') - .mockReturnValue(validatorsUpdateResult); - const lastCertificate = { - height: 4, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }; - chainConnectorPlugin['_lastCertificate'] = lastCertificate; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(4), - height: 4, - }); - const validatorsData = [ - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }, - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: lastCertificate.validatorsHash, - }, - ]; - - // Insert CCM with a big size - sampleCCMsWithEvents[7] = { - ccms: [getSampleCCM(8, 10000)], - height: 8, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(1)], - }, - outboxSize: 2, - }; - await chainConnectorPlugin['_chainConnectorStore'].setCrossChainMessages( - sampleCCMsWithEvents, - ); - - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - validatorsData, - sampleCCMsWithEvents, - ); - expect(result?.ccuParams.activeValidatorsUpdate).toEqual( - validatorsUpdateResult.activeValidatorsUpdate, - ); - expect(result?.ccuParams.certificateThreshold).toEqual( - validatorsUpdateResult.certificateThreshold, - ); - expect(result?.ccuParams.certificate).toEqual( - codec.encode(certificateSchema, newCertificate), - ); - expect(result?.ccuParams.inboxUpdate.crossChainMessages.length).toEqual( - sampleCCMsWithEvents.slice(5, 8).length, - ); - expect(result?.ccuParams.inboxUpdate.messageWitnessHashes).toEqual(messageWitnessHashes); - expect(result?.ccuParams.inboxUpdate.outboxRootWitness).toEqual( - sampleCCMsWithEvents[8].inclusionProof, - ); - }); - - it('should return empty list of ccms and message witnesses when no pending ccms', async () => { - when(sendingChainAPIClientMock.invoke) - .calledWith('interoperability_getChannel', { - chainID: chainConnectorPlugin['_receivingChainID'].toString('hex'), - }) - .mockResolvedValue({ - ...sampleChannelDataJSON, - outbox: { ...sampleChannelDataJSON.outbox, size: 4 }, - }); - - const newCertificate = { - aggregationBits: Buffer.alloc(1), - blockID: blockHeaderAtCertificateHeight.id as Buffer, - height: blockHeaderAtCertificateHeight.height, - signature: cryptography.utils.getRandomBytes(BLS_SIGNATURE_LENGTH), - stateRoot: cryptography.utils.getRandomBytes(HASH_LENGTH), - timestamp: blockHeaderAtCertificateHeight.timestamp, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }; - jest - .spyOn(certificateGenerationUtil, 'getNextCertificateFromAggregateCommits') - .mockReturnValue(newCertificate); - const validatorsUpdateResult = { - activeValidatorsUpdate: { - blsKeysUpdate: [cryptography.utils.getRandomBytes(54)], - bftWeightsUpdate: [BigInt(1)], - bftWeightsUpdateBitmap: Buffer.alloc(1), - }, - certificateThreshold: BigInt(79), - }; - jest - .spyOn(activeValidatorsUpdateUtil, 'calculateActiveValidatorsUpdate') - .mockReturnValue(validatorsUpdateResult); - const lastCertificate = { - height: 4, - stateRoot: Buffer.alloc(1), - timestamp: Date.now(), - validatorsHash: cryptography.utils.getRandomBytes(HASH_LENGTH), - }; - chainConnectorPlugin['_lastCertificate'] = lastCertificate; - await chainConnectorPlugin['_chainConnectorStore'].setLastSentCCM({ - ...getSampleCCM(4), - height: 4, - }); - const validatorsData = [ - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: blockHeaderAtCertificateHeight.validatorsHash, - }, - { - certificateThreshold: BigInt(78), - validators: sampleValidators, - validatorsHash: lastCertificate.validatorsHash, - }, - ]; - - // Empty ccms for certificate height - const result = await chainConnectorPlugin['_computeCCUParams']( - sampleBlockHeaders, - sampleAggregateCommits, - validatorsData, - [ - { - ccms: [], - height: 9, - inclusionProof: { - bitmap: Buffer.alloc(1), - siblingHashes: [Buffer.alloc(1)], - }, - outboxSize: 2, - }, - ], - ); - expect(result?.ccuParams.activeValidatorsUpdate).toEqual( - validatorsUpdateResult.activeValidatorsUpdate, - ); - expect(result?.ccuParams.certificateThreshold).toEqual( - validatorsUpdateResult.certificateThreshold, - ); - expect(result?.ccuParams.certificate).toEqual( - codec.encode(certificateSchema, newCertificate), - ); - expect(result?.ccuParams.inboxUpdate.crossChainMessages).toEqual([]); - expect(result?.ccuParams.inboxUpdate.messageWitnessHashes).toEqual([]); - expect(result?.ccuParams.inboxUpdate.outboxRootWitness).toEqual({ - bitmap: EMPTY_BYTES, - siblingHashes: [], - }); - }); - }); - }); - - describe('_submitCCU', () => { - beforeEach(async () => { - jest - .spyOn(apiClient, 'createIPCClient') - .mockResolvedValue(sendingChainAPIClientMock as never); - await initChainConnectorPlugin(chainConnectorPlugin, defaultConfig); - (chainConnectorPlugin['_receivingChainClient'] as any) = sendingChainAPIClientMock; - when(sendingChainAPIClientMock.invoke) - .calledWith('system_getNodeInfo') - .mockResolvedValue({ - chainID: '10000000', - }) - .calledWith('txpool_postTransaction', expect.anything()) - .mockResolvedValue({ - transactionId: 'transaction-id', - }) - .calledWith('auth_getAuthAccount', expect.anything()) - .mockResolvedValue({ nonce: '3' }); - when(sendingChainAPIClientMock.invoke) - .calledWith('token_hasUserAccount', expect.anything()) - .mockResolvedValue({ - exists: true, - }); - - await chainConnectorPlugin['_submitCCU'](sampleCCUParams); - }); - - it('should get the chainID from the node', () => { - expect(sendingChainAPIClientMock.invoke).toHaveBeenCalledWith('system_getNodeInfo'); - }); - - it('should get the current nonce for the account', () => { - expect(sendingChainAPIClientMock.invoke).toHaveBeenCalledWith('auth_getAuthAccount', { - address: expect.any(String), - }); - }); - - it('should create and post the CCUs', () => { - expect(sendingChainAPIClientMock.invoke).toHaveBeenCalledWith('txpool_postTransaction', { - transaction: expect.any(String), - }); - expect(sendingChainAPIClientMock.invoke).toHaveBeenNthCalledWith( - 1, - 'auth_getAuthAccount', - expect.anything(), - ); - expect(sendingChainAPIClientMock.invoke).toHaveBeenNthCalledWith(2, 'system_getNodeInfo'); - expect(sendingChainAPIClientMock.invoke).toHaveBeenNthCalledWith( - 3, - 'token_hasUserAccount', - expect.anything(), - ); - expect(sendingChainAPIClientMock.invoke).toHaveBeenNthCalledWith( - 4, - 'txpool_postTransaction', - expect.anything(), - ); - }); - }); -});