Skip to content
This repository has been archived by the owner on Jun 11, 2024. It is now read-only.

Missing test(s) for message recovery #9137

Merged
merged 8 commits into from
Nov 23, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class RecoverMessageCommand extends BaseInteroperabilityCommand<Mainchain
};
}

// Check that there is exactly one index per cross-chain message.
if (idxs.length !== crossChainMessages.length) {
return {
status: VerifyStatus.FAIL,
Expand All @@ -87,6 +88,7 @@ export class RecoverMessageCommand extends BaseInteroperabilityCommand<Mainchain
};
}

// Check that the idxs are strictly increasing
for (let i = 0; i < idxs.length - 1; i += 1) {
if (idxs[i] > idxs[i + 1]) {
return {
Expand All @@ -96,20 +98,17 @@ export class RecoverMessageCommand extends BaseInteroperabilityCommand<Mainchain
}
}

// Ensure that there are at least two bits, i.e. the value must be larger than 1.
// It's sufficient to check only the first one due the ascending order.
// Check that the CCMs are still pending. We check only the first one,
// as the idxs are sorted in ascending order. Note that one must unset the most significant
// bit of an encoded index in idxs in order to get the position in the tree. To do this
// we must ensure that there are at least two bits, i.e. the value must be larger than 1.
// See https://github.com/LiskHQ/lips/blob/main/proposals/lip-0031.md#proof-serialization.
if (idxs[0] <= 1) {
return {
status: VerifyStatus.FAIL,
error: new Error('Cross-chain message does not have a valid index.'),
};
}

// Check that the CCMs are still pending. We can check only the first one,
// as the idxs are sorted in ascending order. Note that one must unset the most significant
// bit a of an encoded index in idxs in order to get the position in the tree.
// See https://github.com/LiskHQ/lips/blob/main/proposals/lip-0031.md#proof-serialization.
const firstPosition = parseInt(idxs[0].toString(2).slice(1), 2);
if (firstPosition < terminatedOutboxAccount.partnerChainInboxSize) {
return {
Expand Down Expand Up @@ -138,7 +137,9 @@ export class RecoverMessageCommand extends BaseInteroperabilityCommand<Mainchain
if (ccm.status !== CCMStatusCode.OK) {
return {
status: VerifyStatus.FAIL,
error: new Error('Cross-chain message status is not valid.'),
error: new Error(
`Cross-chain message status must be equal to value ${CCMStatusCode.OK}.`,
),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { codec } from '@liskhq/lisk-codec';
import { Transaction } from '@liskhq/lisk-chain';
import { utils } from '@liskhq/lisk-cryptography';
import { MerkleTree } from '@liskhq/lisk-tree';
import { MerkleTree, regularMerkleTree } from '@liskhq/lisk-tree';
import { Proof } from '@liskhq/lisk-tree/dist-node/merkle_tree/types';
import {
CROSS_CHAIN_COMMAND_NAME_TRANSFER,
Expand Down Expand Up @@ -286,7 +286,7 @@ describe('MessageRecoveryCommand', () => {
expect(result.error?.message).toInclude(`Cross-chain message does not have a valid index.`);
});

it('should return error if idxs[0] <= 1', async () => {
it('should return error if idxs[0] === 1', async () => {
transactionParams.idxs = [1];
ccms = [ccms[0]];
ccmsEncoded = ccms.map(ccm => codec.encode(ccmSchema, ccm));
Expand Down Expand Up @@ -366,6 +366,42 @@ describe('MessageRecoveryCommand', () => {
expect(result.error?.message).toInclude(`Cross-chain message was never in the outbox.`);
});

it('should return error if ccm has invalid schema', async () => {
ccms = [
{
nonce: BigInt(0),
module: MODULE_NAME_INTEROPERABILITY,
crossChainCommand: CROSS_CHAIN_COMMAND_REGISTRATION,
sendingChainID: utils.intToBuffer(0, 2), // ***
receivingChainID: utils.intToBuffer(3, 4),
fee: BigInt(1),
status: CCMStatusCode.FAILED_CCM,
params: Buffer.alloc(0),
},
];
ccmsEncoded = ccms.map(ccm => codec.encode(ccmSchema, ccm));
transactionParams.crossChainMessages = [...ccmsEncoded];
transactionParams.idxs = appendPrecedingToIndices([1], terminatedChainOutboxSize);

commandVerifyContext = createCommandVerifyContext(transaction, transactionParams);

await interopModule.stores
.get(TerminatedOutboxStore)
.set(createStoreGetter(commandVerifyContext.stateStore as any), chainID, {
outboxRoot,
outboxSize: terminatedChainOutboxSize,
partnerChainInboxSize: 0,
});

try {
await command.verify(commandVerifyContext);
} catch (err: any) {
expect((err as Error).message).toInclude(
`Property '.sendingChainID' minLength not satisfied`,
);
}
});

it('should return error if ccm.status !== CCMStatusCode.OK', async () => {
ccms = [
{
Expand Down Expand Up @@ -396,7 +432,9 @@ describe('MessageRecoveryCommand', () => {
const result = await command.verify(commandVerifyContext);

expect(result.status).toBe(VerifyStatus.FAIL);
expect(result.error?.message).toInclude(`Cross-chain message status is not valid.`);
expect(result.error?.message).toInclude(
`Cross-chain message status must be equal to value ${CCMStatusCode.OK}.`,
);
});

it('should return error if cross-chain message receiving chain ID is not valid', async () => {
Expand Down Expand Up @@ -536,6 +574,13 @@ describe('MessageRecoveryCommand', () => {

await expect(command.execute(commandExecuteContext)).resolves.toBeUndefined();

expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
1,
CONTEXT_STORE_KEY_CCM_PROCESSING,
true,
);

const recoveredCCMs: Buffer[] = [];
for (const crossChainMessage of commandExecuteContext.params.crossChainMessages) {
const ccm = codec.decode<CCMsg>(ccmSchema, crossChainMessage);
const ctx: CrossChainMessageContext = {
Expand All @@ -546,25 +591,53 @@ describe('MessageRecoveryCommand', () => {
),
};

const recoveredCCM = await command['_applyRecovery'](ctx);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

due to this, the expectation in line 597 will be met, even though it was not triggered from the function you are testing, right? Alternatively, you could create a copy of ctx, change the status of ccm to CCM_STATUS_CODE_RECOVERED and swap ccm.sendingChainID and ccm.receivingChainID.

recoveredCCMs.push(codec.encode(ccmSchema, recoveredCCM));

expect(command['_applyRecovery']).toHaveBeenCalledWith(ctx);
expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
1,
CONTEXT_STORE_KEY_CCM_PROCESSING,
true,
);
expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
2,
CONTEXT_STORE_KEY_CCM_PROCESSING,
false,
);
}

expect(interopModule.stores.get(TerminatedOutboxStore).set).toHaveBeenCalledTimes(1);
expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
2,
CONTEXT_STORE_KEY_CCM_PROCESSING,
false,
);

const terminatedOutboxSubstore = interopModule.stores.get(TerminatedOutboxStore);
jest.spyOn(terminatedOutboxSubstore, 'set');

expect(terminatedOutboxSubstore.set).toHaveBeenCalledTimes(1);

const terminatedOutboxAccount = await terminatedOutboxSubstore.get(
commandExecuteContext,
commandExecuteContext.params.chainID,
);
const proofLocal = {
size: terminatedOutboxAccount.outboxSize,
idxs: commandExecuteContext.params.idxs,
siblingHashes: commandExecuteContext.params.siblingHashes,
};
terminatedOutboxAccount.outboxRoot = regularMerkleTree.calculateRootFromUpdateData(
recoveredCCMs.map(ccm => utils.hash(ccm)),
{ ...proofLocal, indexes: proofLocal.idxs },
);
expect(terminatedOutboxSubstore.set).toHaveBeenCalledWith(
commandExecuteContext,
commandExecuteContext.params.chainID,
terminatedOutboxAccount,
);
});

it('should call forwardRecovery when sending chain is not mainchain', async () => {
await expect(command.execute(commandExecuteContext)).resolves.toBeUndefined();

expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
1,
CONTEXT_STORE_KEY_CCM_PROCESSING,
true,
);

const recoveredCCMs: Buffer[] = [];
for (const crossChainMessage of commandExecuteContext.params.crossChainMessages) {
const ccm = codec.decode<CCMsg>(ccmSchema, crossChainMessage);
const ctx: CrossChainMessageContext = {
Expand All @@ -575,23 +648,41 @@ describe('MessageRecoveryCommand', () => {
),
};

const recoveredCCM = await command['_forwardRecovery'](ctx);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

recoveredCCMs.push(codec.encode(ccmSchema, recoveredCCM));

expect(command['_forwardRecovery']).toHaveBeenCalledWith(ctx);
}

const terminatedOutboxStore = command['stores'].get(TerminatedOutboxStore);
jest.spyOn(terminatedOutboxStore, 'set');

expect(terminatedOutboxStore.set).toHaveBeenCalledTimes(1);
expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
1,
CONTEXT_STORE_KEY_CCM_PROCESSING,
true,
);
expect(commandExecuteContext.contextStore.set).toHaveBeenNthCalledWith(
2,
CONTEXT_STORE_KEY_CCM_PROCESSING,
false,
);

const terminatedOutboxSubstore = command['stores'].get(TerminatedOutboxStore);
const terminatedOutboxAccount = await terminatedOutboxSubstore.get(
commandExecuteContext,
commandExecuteContext.params.chainID,
);
jest.spyOn(terminatedOutboxSubstore, 'set');

expect(terminatedOutboxSubstore.set).toHaveBeenCalledTimes(1);

const proofLocal = {
size: terminatedOutboxAccount.outboxSize,
idxs: commandExecuteContext.params.idxs,
siblingHashes: commandExecuteContext.params.siblingHashes,
};
terminatedOutboxAccount.outboxRoot = regularMerkleTree.calculateRootFromUpdateData(
recoveredCCMs.map(ccm => utils.hash(ccm)),
{ ...proofLocal, indexes: proofLocal.idxs },
);
expect(terminatedOutboxSubstore.set).toHaveBeenCalledWith(
commandExecuteContext,
commandExecuteContext.params.chainID,
terminatedOutboxAccount,
);
});
});

Expand Down