diff --git a/framework/src/modules/dpos_v2/commands/unlock.ts b/framework/src/modules/dpos_v2/commands/unlock.ts index dacf6869988..ed5d8b7fb08 100644 --- a/framework/src/modules/dpos_v2/commands/unlock.ts +++ b/framework/src/modules/dpos_v2/commands/unlock.ts @@ -16,28 +16,35 @@ import { CommandExecuteContext } from '../../../node/state_machine/types'; import { BaseCommand } from '../../base_command'; import { COMMAND_ID_UNLOCK, + defaultConfig, + EMPTY_KEY, STORE_PREFIX_DELEGATE, + STORE_PREFIX_GENESIS_DATA, STORE_PREFIX_VOTER, MODULE_ID_DPOS, } from '../constants'; -import { delegateStoreSchema, voterStoreSchema } from '../schemas'; +import { delegateStoreSchema, genesisDataStoreSchema, voterStoreSchema } from '../schemas'; import { + BFTAPI, DelegateAccount, + GenesisData, TokenAPI, TokenIDDPoS, UnlockCommandDependencies, VoterData, } from '../types'; -import { hasWaited, isPunished } from '../utils'; +import { hasWaited, isPunished, isCertificateGenerated } from '../utils'; export class UnlockCommand extends BaseCommand { public id = COMMAND_ID_UNLOCK; public name = 'unlockToken'; + private _bftAPI!: BFTAPI; private _tokenAPI!: TokenAPI; private _tokenIDDPoS!: TokenIDDPoS; public addDependencies(args: UnlockCommandDependencies) { + this._bftAPI = args.bftAPI; this._tokenAPI = args.tokenAPI; this._tokenIDDPoS = args.tokenIDDPoS; } @@ -53,6 +60,13 @@ export class UnlockCommand extends BaseCommand { const voterSubstore = getStore(this.moduleID, STORE_PREFIX_VOTER); const voterData = await voterSubstore.getWithSchema(senderAddress, voterStoreSchema); const ineligibleUnlocks = []; + const genesisDataStore = context.getStore(MODULE_ID_DPOS, STORE_PREFIX_GENESIS_DATA); + const genesisData = await genesisDataStore.getWithSchema( + EMPTY_KEY, + genesisDataStoreSchema, + ); + const { height: genesisHeight } = genesisData; + const { maxHeightCertified } = await this._bftAPI.getBFTHeights(getAPIContext()); for (const unlockObject of voterData.pendingUnlocks) { const { pomHeights } = await delegateSubstore.getWithSchema( @@ -62,7 +76,13 @@ export class UnlockCommand extends BaseCommand { if ( hasWaited(unlockObject, senderAddress, height) && - !isPunished(unlockObject, pomHeights, senderAddress, height) + !isPunished(unlockObject, pomHeights, senderAddress, height) && + isCertificateGenerated({ + unlockObject, + genesisHeight, + maxHeightCertified, + roundLength: defaultConfig.roundLength, + }) ) { await this._tokenAPI.unlock( getAPIContext(), diff --git a/framework/src/modules/dpos_v2/module.ts b/framework/src/modules/dpos_v2/module.ts index e99b91d9b37..ac0f27450dd 100644 --- a/framework/src/modules/dpos_v2/module.ts +++ b/framework/src/modules/dpos_v2/module.ts @@ -40,6 +40,7 @@ import { STORE_PREFIX_NAME, STORE_PREFIX_VOTER, defaultConfig, + COMMAND_ID_UNLOCK, } from './constants'; import { DPoSEndpoint } from './endpoint'; import { @@ -147,6 +148,20 @@ export class DPoSModule extends BaseModule { throw new Error("'voteCommand' is missing from DPoS module"); } voteCommand.init({ tokenIDDPoS: this._moduleConfig.tokenIDDPoS }); + + const unlockCommand = this.commands.find(command => command.id === COMMAND_ID_UNLOCK) as + | UnlockCommand + | undefined; + if (!unlockCommand) { + throw new Error("'unlockCommand' is missing from DPoS module"); + } + + // Dependency added here since we need access to moduleConfig for tokenIDDPoS + unlockCommand.addDependencies({ + bftAPI: this._bftAPI, + tokenAPI: this._tokenAPI, + tokenIDDPoS: this._moduleConfig.tokenIDDPoS, + }); } public async initGenesisState(context: GenesisBlockExecuteContext): Promise { diff --git a/framework/src/modules/dpos_v2/types.ts b/framework/src/modules/dpos_v2/types.ts index 6d3f2236b98..244578d8732 100644 --- a/framework/src/modules/dpos_v2/types.ts +++ b/framework/src/modules/dpos_v2/types.ts @@ -13,6 +13,7 @@ */ import { BlockHeader } from '@liskhq/lisk-chain'; +import { BFTHeights } from '../bft/types'; import { Validator } from '../../node/consensus/types'; import { APIContext, ImmutableAPIContext } from '../../node/state_machine/types'; @@ -54,6 +55,7 @@ export interface BFTAPI { validators: Validator[]; }>; areHeadersContradicting(bftHeader1: BlockHeader, bftHeader2: BlockHeader): boolean; + getBFTHeights(context: ImmutableAPIContext): Promise; } export interface RandomAPI { @@ -219,6 +221,7 @@ export interface ValidatorKeys { export interface UnlockCommandDependencies { tokenIDDPoS: TokenIDDPoS; tokenAPI: TokenAPI; + bftAPI: BFTAPI; } export interface SnapshotStoreData { @@ -234,7 +237,7 @@ export interface PreviousTimestampData { } export interface GenesisData { - heigth: number; + height: number; initRounds: number; initDelegates: Buffer[]; } diff --git a/framework/src/modules/dpos_v2/utils.ts b/framework/src/modules/dpos_v2/utils.ts index 6361147473e..12b8c6a7f6d 100644 --- a/framework/src/modules/dpos_v2/utils.ts +++ b/framework/src/modules/dpos_v2/utils.ts @@ -234,6 +234,24 @@ export const isPunished = ( ); }; +export const lastHeightOfRound = (height: number, genesisHeight: number, roundLength: number) => { + const roundNumber = Math.ceil((height - genesisHeight) / roundLength); + + return roundNumber * roundLength + genesisHeight; +}; + +export const isCertificateGenerated = (options: { + unlockObject: UnlockingObject; + genesisHeight: number; + maxHeightCertified: number; + roundLength: number; +}): boolean => + lastHeightOfRound( + options.unlockObject.unvoteHeight + 2 * options.roundLength, + options.genesisHeight, + options.roundLength, + ) <= options.maxHeightCertified; + export const getMinPunishedHeight = ( senderAddress: Buffer, delegateAddress: Buffer, diff --git a/framework/test/unit/modules/dpos_v2/commands/pom.spec.ts b/framework/test/unit/modules/dpos_v2/commands/pom.spec.ts index 72c04df6e95..568b8e58091 100644 --- a/framework/test/unit/modules/dpos_v2/commands/pom.spec.ts +++ b/framework/test/unit/modules/dpos_v2/commands/pom.spec.ts @@ -91,6 +91,7 @@ describe('ReportDelegateMisbehaviorCommand', () => { setBFTParameters: jest.fn(), getBFTParameters: jest.fn(), areHeadersContradicting: jest.fn(), + getBFTHeights: jest.fn(), }; mockValidatorsAPI = { setGeneratorList: jest.fn(), diff --git a/framework/test/unit/modules/dpos_v2/commands/unlock.spec.ts b/framework/test/unit/modules/dpos_v2/commands/unlock.spec.ts index 797b90fb24d..3b8de573f40 100644 --- a/framework/test/unit/modules/dpos_v2/commands/unlock.spec.ts +++ b/framework/test/unit/modules/dpos_v2/commands/unlock.spec.ts @@ -18,17 +18,28 @@ import { InMemoryKVStore, KVStore } from '@liskhq/lisk-db'; import * as testing from '../../../../../src/testing'; import { UnlockCommand } from '../../../../../src/modules/dpos_v2/commands/unlock'; import { + EMPTY_KEY, SELF_VOTE_PUNISH_TIME, VOTER_PUNISH_TIME, WAIT_TIME_SELF_VOTE, WAIT_TIME_VOTE, MODULE_ID_DPOS, STORE_PREFIX_DELEGATE, + STORE_PREFIX_GENESIS_DATA, STORE_PREFIX_VOTER, COMMAND_ID_UNLOCK, } from '../../../../../src/modules/dpos_v2/constants'; -import { delegateStoreSchema, voterStoreSchema } from '../../../../../src/modules/dpos_v2/schemas'; -import { TokenAPI, UnlockingObject, VoterData } from '../../../../../src/modules/dpos_v2/types'; +import { + delegateStoreSchema, + genesisDataStoreSchema, + voterStoreSchema, +} from '../../../../../src/modules/dpos_v2/schemas'; +import { + BFTAPI, + TokenAPI, + UnlockingObject, + VoterData, +} from '../../../../../src/modules/dpos_v2/types'; import { CommandExecuteContext } from '../../../../../src/node/state_machine/types'; import { liskToBeddows } from '../../../../utils/assets'; @@ -38,7 +49,9 @@ describe('UnlockCommand', () => { let stateStore: StateStore; let delegateSubstore: StateStore; let voterSubstore: StateStore; + let genesisSubstore: StateStore; let mockTokenAPI: TokenAPI; + let mockBFTAPI: BFTAPI; let blockHeight: number; let header: BlockHeader; let unlockableObject: UnlockingObject; @@ -84,20 +97,37 @@ describe('UnlockCommand', () => { transfer: jest.fn(), getLockedAmount: jest.fn(), }; + mockBFTAPI = { + setBFTParameters: jest.fn(), + getBFTParameters: jest.fn(), + areHeadersContradicting: jest.fn(), + getBFTHeights: jest.fn().mockResolvedValue({ maxHeightCertified: 8760000 }), + }; unlockCommand.addDependencies({ tokenIDDPoS: { chainID: 0, localID: 0 }, tokenAPI: mockTokenAPI, + bftAPI: mockBFTAPI, }); db = new InMemoryKVStore() as never; stateStore = new StateStore(db); delegateSubstore = stateStore.getStore(MODULE_ID_DPOS, STORE_PREFIX_DELEGATE); voterSubstore = stateStore.getStore(MODULE_ID_DPOS, STORE_PREFIX_VOTER); + genesisSubstore = stateStore.getStore(MODULE_ID_DPOS, STORE_PREFIX_GENESIS_DATA); blockHeight = 8760000; header = testing.createFakeBlockHeader({ height: blockHeight }); }); describe(`when non self-voted non-punished account waits ${WAIT_TIME_VOTE} blocks since unvoteHeight`, () => { beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); await delegateSubstore.setWithSchema( delegate1.address, { @@ -114,6 +144,7 @@ describe('UnlockCommand', () => { }, delegateStoreSchema, ); + unlockableObject = { delegateAddress: delegate1.address, amount: delegate1.amount, @@ -160,6 +191,15 @@ describe('UnlockCommand', () => { describe(`when self-voted non-punished account waits ${WAIT_TIME_SELF_VOTE} blocks since unvoteHeight`, () => { beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); await delegateSubstore.setWithSchema( transaction.senderAddress, { @@ -215,6 +255,15 @@ describe('UnlockCommand', () => { describe(`when non self-voted punished account waits ${VOTER_PUNISH_TIME} blocks and unvoteHeight + ${WAIT_TIME_VOTE} blocks since last pomHeight`, () => { beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); await delegateSubstore.setWithSchema( delegate1.address, { @@ -328,6 +377,15 @@ describe('UnlockCommand', () => { describe(`when self-voted punished account waits ${SELF_VOTE_PUNISH_TIME} blocks and waits unvoteHeight + ${WAIT_TIME_SELF_VOTE} blocks since pomHeight`, () => { beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); await delegateSubstore.setWithSchema( transaction.senderAddress, { @@ -387,6 +445,15 @@ describe('UnlockCommand', () => { describe(`when self-voted punished account does not wait ${SELF_VOTE_PUNISH_TIME} blocks and waits unvoteHeight + ${WAIT_TIME_SELF_VOTE} blocks since pomHeight`, () => { beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); await delegateSubstore.setWithSchema( transaction.senderAddress, { @@ -430,4 +497,138 @@ describe('UnlockCommand', () => { ); }); }); + + describe(`when certificate is not generated`, () => { + beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 10, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); + await delegateSubstore.setWithSchema( + delegate1.address, + { + name: delegate1.name, + ...defaultDelegateInfo, + }, + delegateStoreSchema, + ); + await delegateSubstore.setWithSchema( + delegate2.address, + { + name: delegate2.name, + ...defaultDelegateInfo, + }, + delegateStoreSchema, + ); + nonUnlockableObject = { + delegateAddress: delegate2.address, + amount: delegate2.amount, + unvoteHeight: blockHeight, + }; + await voterSubstore.setWithSchema( + transaction.senderAddress, + { + sentVotes: [ + { delegateAddress: unlockableObject.delegateAddress, amount: unlockableObject.amount }, + ], + pendingUnlocks: [nonUnlockableObject], + }, + voterStoreSchema, + ); + }); + + it('should not unlock any votes', async () => { + // Arrange + mockBFTAPI.getBFTHeights = jest.fn().mockResolvedValue({ maxHeightCertified: 0 }); + context = testing + .createTransactionContext({ + stateStore, + transaction, + header, + networkIdentifier, + }) + .createCommandExecuteContext(); + + await expect(unlockCommand.execute(context)).rejects.toThrow( + 'No eligible voter data was found for unlocking', + ); + }); + }); + + describe(`when certificate is generated`, () => { + beforeEach(async () => { + await genesisSubstore.setWithSchema( + EMPTY_KEY, + { + height: 8760000, + initRounds: 1, + initDelegates: [], + }, + genesisDataStoreSchema, + ); + await delegateSubstore.setWithSchema( + delegate1.address, + { + name: delegate1.name, + ...defaultDelegateInfo, + }, + delegateStoreSchema, + ); + await delegateSubstore.setWithSchema( + delegate2.address, + { + name: delegate2.name, + ...defaultDelegateInfo, + }, + delegateStoreSchema, + ); + + unlockableObject = { + delegateAddress: delegate1.address, + amount: delegate1.amount, + unvoteHeight: blockHeight - WAIT_TIME_VOTE, + }; + nonUnlockableObject = { + delegateAddress: delegate2.address, + amount: delegate2.amount, + unvoteHeight: blockHeight, + }; + await voterSubstore.setWithSchema( + transaction.senderAddress, + { + sentVotes: [ + { delegateAddress: unlockableObject.delegateAddress, amount: unlockableObject.amount }, + ], + pendingUnlocks: [unlockableObject, nonUnlockableObject], + }, + voterStoreSchema, + ); + context = testing + .createTransactionContext({ + stateStore, + transaction, + header, + networkIdentifier, + }) + .createCommandExecuteContext(); + await unlockCommand.execute(context); + storedData = await voterSubstore.getWithSchema( + transaction.senderAddress, + voterStoreSchema, + ); + }); + + it('should remove eligible pending unlock from voter substore', () => { + expect(storedData.pendingUnlocks).not.toContainEqual(unlockableObject); + }); + + it('should not remove ineligible pending unlock from voter substore', () => { + expect(storedData.pendingUnlocks).toContainEqual(nonUnlockableObject); + }); + }); }); diff --git a/framework/test/unit/modules/dpos_v2/module.spec.ts b/framework/test/unit/modules/dpos_v2/module.spec.ts index c0c30fc67ce..a4b7ee5b22d 100644 --- a/framework/test/unit/modules/dpos_v2/module.spec.ts +++ b/framework/test/unit/modules/dpos_v2/module.spec.ts @@ -106,6 +106,18 @@ describe('DPoS module', () => { expect(dpos['_moduleConfig'].maxLengthName).toEqual(50); }); + + it('should throw error if command missing', async () => { + dpos.commands = []; + + await expect( + dpos.init({ + genesisConfig: {} as any, + moduleConfig: { ...defaultConfigs, maxLengthName: 50 }, + generatorConfig: {}, + }), + ).rejects.toThrow("'voteCommand' is missing from DPoS module"); + }); }); describe('initGenesisState', () => { @@ -122,6 +134,7 @@ describe('DPoS module', () => { setBFTParameters: jest.fn(), getBFTParameters: jest.fn(), areHeadersContradicting: jest.fn(), + getBFTHeights: jest.fn(), }; const validatorAPI = { setGeneratorList: jest.fn(), @@ -139,8 +152,8 @@ describe('DPoS module', () => { transfer: jest.fn(), getLockedAmount: jest.fn().mockResolvedValue(BigInt(101000000000)), }; - dpos.addDependencies(randomAPI, bftAPI, validatorAPI, tokenAPI); + await dpos.init({ generatorConfig: {}, genesisConfig: {} as GenesisConfig, @@ -778,6 +791,7 @@ describe('DPoS module', () => { bftWeight: BigInt(1), })), }), + getBFTHeights: jest.fn(), areHeadersContradicting: jest.fn(), }; const validatorAPI = { @@ -882,6 +896,7 @@ describe('DPoS module', () => { })), }), areHeadersContradicting: jest.fn(), + getBFTHeights: jest.fn(), }; validatorAPI = { setGeneratorList: jest.fn(), @@ -1448,7 +1463,7 @@ describe('DPoS module', () => { describe('afterTransactionsExecute', () => { const genesisData: GenesisData = { - heigth: 0, + height: 0, initRounds: 3, initDelegates: [], };