From 18f7bbebf43bb8ce683be44b62cf339bd5e76f55 Mon Sep 17 00:00:00 2001 From: shuse2 Date: Mon, 7 Nov 2022 16:17:43 +0100 Subject: [PATCH] Add dynamic reward module (#7737) ### What was the problem? This PR resolves #7717 ### How was it solved? - Add new dynamic reward module using reward module - Update genesis block for example app - Add small refactor to reward module to be usable from dynamic reward module - Add new DPoS method which is required by dynamic reward module - #7715 should add the implementation for `updateSharedRewards` ### How was it tested? - Add unit tests for all the cases --- .../config/default/genesis_block.blob | Bin 30436 -> 30436 bytes framework/src/application.ts | 23 +- framework/src/modules/dpos_v2/method.ts | 41 +- framework/src/modules/dpos_v2/module.ts | 14 +- .../src/modules/dynamic_rewards/constants.ts | 24 ++ .../src/modules/dynamic_rewards/endpoint.ts | 16 + .../src/modules/dynamic_rewards/index.ts | 16 + .../src/modules/dynamic_rewards/method.ts | 34 ++ .../src/modules/dynamic_rewards/module.ts | 250 +++++++++++ .../src/modules/dynamic_rewards/schemas.ts | 27 ++ .../dynamic_rewards/stores/block_rewards.ts | 34 ++ .../stores/end_of_round_timestamp.ts | 34 ++ .../src/modules/dynamic_rewards/types.ts | 72 ++++ .../src/modules/reward/calculate_reward.ts | 19 +- framework/src/modules/reward/constants.ts | 2 +- framework/src/modules/reward/endpoint.ts | 21 +- framework/src/modules/reward/method.ts | 21 +- framework/src/modules/reward/module.ts | 28 +- framework/src/modules/reward/types.ts | 30 +- .../modules/dynamic_rewards/module.spec.ts | 387 ++++++++++++++++++ .../unit/modules/reward/reward_module.spec.ts | 8 +- 21 files changed, 996 insertions(+), 105 deletions(-) create mode 100644 framework/src/modules/dynamic_rewards/constants.ts create mode 100644 framework/src/modules/dynamic_rewards/endpoint.ts create mode 100644 framework/src/modules/dynamic_rewards/index.ts create mode 100644 framework/src/modules/dynamic_rewards/method.ts create mode 100644 framework/src/modules/dynamic_rewards/module.ts create mode 100644 framework/src/modules/dynamic_rewards/schemas.ts create mode 100644 framework/src/modules/dynamic_rewards/stores/block_rewards.ts create mode 100644 framework/src/modules/dynamic_rewards/stores/end_of_round_timestamp.ts create mode 100644 framework/src/modules/dynamic_rewards/types.ts create mode 100644 framework/test/unit/modules/dynamic_rewards/module.spec.ts diff --git a/examples/dpos-mainchain/config/default/genesis_block.blob b/examples/dpos-mainchain/config/default/genesis_block.blob index c3241f82c4a80a5b640e5fd90d263ac02f7fb5a0..fa2ff0f47c65ffc891447ebed8f1815ee1f19bc6 100644 GIT binary patch delta 65 zcmV-H0KWg^?E&QN0S^lN0SEvPoVui&1{eS$AdwLsku88A=@dV0)=F;_2*IElV3n_? Xd5fMz7VpKPa=hf+4yPujvCS8DOdA`^ delta 65 zcmV-H0KWg^?E&QN0S^lN0SEvP{^5_C1{eS$AdwLsku88AoMq&MHXYB_v9Or@&o0u= X2O?O%V@XJF5w5{lMvAhzvCS8DkD?t8 diff --git a/framework/src/application.ts b/framework/src/application.ts index c275c928657..9fde2b03d01 100644 --- a/framework/src/application.ts +++ b/framework/src/application.ts @@ -43,17 +43,19 @@ import { ValidatorsMethod, ValidatorsModule } from './modules/validators'; import { TokenModule, TokenMethod } from './modules/token'; import { AuthModule, AuthMethod } from './modules/auth'; import { FeeModule, FeeMethod } from './modules/fee'; -import { RewardModule, RewardMethod } from './modules/reward'; import { RandomModule, RandomMethod } from './modules/random'; import { DPoSModule, DPoSMethod } from './modules/dpos_v2'; import { generateGenesisBlock, GenesisBlockGenerateInput } from './genesis_block'; import { StateMachine } from './state_machine'; import { ABIHandler, EVENT_ENGINE_READY } from './abi_handler/abi_handler'; import { ABIServer } from './abi_handler/abi_server'; -import { SidechainInteroperabilityModule } from './modules/interoperability/sidechain/module'; -import { MainchainInteroperabilityModule } from './modules/interoperability/mainchain/module'; -import { SidechainInteroperabilityMethod } from './modules/interoperability/sidechain/method'; -import { MainchainInteroperabilityMethod } from './modules/interoperability/mainchain/method'; +import { + SidechainInteroperabilityModule, + MainchainInteroperabilityModule, + SidechainInteroperabilityMethod, + MainchainInteroperabilityMethod, +} from './modules/interoperability'; +import { DynamicRewardMethod, DynamicRewardModule } from './modules/dynamic_rewards'; const isPidRunning = async (pid: number): Promise => psList().then(list => list.some(x => x.pid === pid)); @@ -109,7 +111,7 @@ interface DefaultApplication { token: TokenMethod; fee: FeeMethod; random: RandomMethod; - reward: RewardMethod; + reward: DynamicRewardMethod; dpos: DPoSMethod; interoperability: SidechainInteroperabilityMethod | MainchainInteroperabilityMethod; }; @@ -163,7 +165,7 @@ export class Application { const authModule = new AuthModule(); const tokenModule = new TokenModule(); const feeModule = new FeeModule(); - const rewardModule = new RewardModule(); + const rewardModule = new DynamicRewardModule(); const randomModule = new RandomModule(); const validatorModule = new ValidatorsModule(); const dposModule = new DPoSModule(); @@ -178,7 +180,12 @@ export class Application { // resolve dependencies feeModule.addDependencies(tokenModule.method); - rewardModule.addDependencies(tokenModule.method, randomModule.method); + rewardModule.addDependencies( + tokenModule.method, + randomModule.method, + validatorModule.method, + dposModule.method, + ); dposModule.addDependencies(randomModule.method, validatorModule.method, tokenModule.method); tokenModule.addDependencies(interoperabilityModule.method); diff --git a/framework/src/modules/dpos_v2/method.ts b/framework/src/modules/dpos_v2/method.ts index 0c4414cd24b..fd1e9d2b292 100644 --- a/framework/src/modules/dpos_v2/method.ts +++ b/framework/src/modules/dpos_v2/method.ts @@ -12,16 +12,22 @@ * Removal or modification of this copyright notice is prohibited. */ -import { ImmutableMethodContext } from '../../state_machine'; +import { ImmutableMethodContext, MethodContext } from '../../state_machine'; import { BaseMethod } from '../base_method'; -import { MAX_LENGTH_NAME } from './constants'; +import { EMPTY_KEY, MAX_LENGTH_NAME } from './constants'; +import { GenesisDataStore } from './stores/genesis'; +import { VoterStore, VoterData } from './stores/voter'; +import { ModuleConfig } from './types'; import { DelegateAccount, DelegateStore } from './stores/delegate'; import { NameStore } from './stores/name'; -import { VoterStore } from './stores/voter'; -import { VoterData } from './types'; import { isUsername } from './utils'; export class DPoSMethod extends BaseMethod { + private _config!: ModuleConfig; + + public init(config: ModuleConfig) { + this._config = config; + } public async isNameAvailable( methodContext: ImmutableMethodContext, name: string, @@ -58,4 +64,31 @@ export class DPoSMethod extends BaseMethod { return delegate; } + + public getRoundLength(_methodContext: ImmutableMethodContext): number { + return this._config.roundLength; + } + + public getNumberOfActiveDelegates(_methodContext: ImmutableMethodContext): number { + return this._config.numberActiveDelegates; + } + + public async updateSharedRewards( + _methodContext: MethodContext, + _generatorAddress: Buffer, + _tokenID: Buffer, + _reward: bigint, + ): Promise { + // TODO: Implement #7715 + } + + public async isEndOfRound( + methodContext: ImmutableMethodContext, + height: number, + ): Promise { + const { height: genesisHeight } = await this.stores + .get(GenesisDataStore) + .get(methodContext, EMPTY_KEY); + return (height - genesisHeight) % this._config.roundLength === 0; + } } diff --git a/framework/src/modules/dpos_v2/module.ts b/framework/src/modules/dpos_v2/module.ts index dfe8548c5af..5b82217cf72 100644 --- a/framework/src/modules/dpos_v2/module.ts +++ b/framework/src/modules/dpos_v2/module.ts @@ -238,6 +238,7 @@ export class DPoSModule extends BaseModule { this._moduleConfig = getModuleConfig(config); + this.method.init(this._moduleConfig); this.endpoint.init(this.name, this._moduleConfig, this._tokenMethod); this._reportDelegateMisbehaviorCommand.init({ @@ -498,7 +499,10 @@ export class DPoSModule extends BaseModule { public async afterTransactionsExecute(context: BlockAfterExecuteContext): Promise { const { header } = context; - const isLastBlockOfRound = this._isLastBlockOfTheRound(header.height); + const isLastBlockOfRound = await this.method.isEndOfRound( + context.getMethodContext(), + header.height, + ); const previousTimestampStore = this.stores.get(PreviousTimestampStore); const previousTimestampData = await previousTimestampStore.get(context, EMPTY_KEY); const { timestamp: previousTimestamp } = previousTimestampData; @@ -675,14 +679,6 @@ export class DPoSModule extends BaseModule { await delegateStore.set(context, header.generatorAddress, generator); } - private _isLastBlockOfTheRound(height: number): boolean { - const rounds = new Rounds({ blocksPerRound: this._moduleConfig.roundLength }); - const currentRound = rounds.calcRound(height); - const nextRound = rounds.calcRound(height + 1); - - return currentRound < nextRound; - } - private async _didBootstrapRoundsEnd(context: BlockAfterExecuteContext) { const { header } = context; const rounds = new Rounds({ blocksPerRound: this._moduleConfig.roundLength }); diff --git a/framework/src/modules/dynamic_rewards/constants.ts b/framework/src/modules/dynamic_rewards/constants.ts new file mode 100644 index 00000000000..4a56255ab96 --- /dev/null +++ b/framework/src/modules/dynamic_rewards/constants.ts @@ -0,0 +1,24 @@ +/* + * Copyright © 2021 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 { defaultConfig as rewardDefaultConfig } from '../reward/constants'; + +export const EMPTY_BYTES = Buffer.alloc(0); + +export const defaultConfig = { + ...rewardDefaultConfig, + factorMinimumRewardActiveDelegates: 1000, +}; + +export const DECIMAL_PERCENT_FACTOR = BigInt(10000); diff --git a/framework/src/modules/dynamic_rewards/endpoint.ts b/framework/src/modules/dynamic_rewards/endpoint.ts new file mode 100644 index 00000000000..0ab3f869e24 --- /dev/null +++ b/framework/src/modules/dynamic_rewards/endpoint.ts @@ -0,0 +1,16 @@ +/* + * Copyright © 2021 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 { RewardEndpoint } from '../reward/endpoint'; + +export class DynamicRewardEndpoint extends RewardEndpoint {} diff --git a/framework/src/modules/dynamic_rewards/index.ts b/framework/src/modules/dynamic_rewards/index.ts new file mode 100644 index 00000000000..1ba29074e3d --- /dev/null +++ b/framework/src/modules/dynamic_rewards/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright © 2021 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. + */ + +export { DynamicRewardModule } from './module'; +export { DynamicRewardMethod } from './method'; diff --git a/framework/src/modules/dynamic_rewards/method.ts b/framework/src/modules/dynamic_rewards/method.ts new file mode 100644 index 00000000000..a3861037d6e --- /dev/null +++ b/framework/src/modules/dynamic_rewards/method.ts @@ -0,0 +1,34 @@ +/* + * Copyright © 2021 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 { ImmutableMethodContext } from '../../state_machine'; +import { BaseMethod } from '../base_method'; +import { calculateDefaultReward } from '../reward/calculate_reward'; +import { ModuleConfig } from './types'; + +export interface MethodInitArgs { + config: ModuleConfig; +} + +export class DynamicRewardMethod extends BaseMethod { + private _config!: ModuleConfig; + + public init(args: MethodInitArgs) { + this._config = args.config; + } + + public getDefaultRewardAtHeight(_context: ImmutableMethodContext, height: number): bigint { + return calculateDefaultReward(this._config, height); + } +} diff --git a/framework/src/modules/dynamic_rewards/module.ts b/framework/src/modules/dynamic_rewards/module.ts new file mode 100644 index 00000000000..e4adee7fda7 --- /dev/null +++ b/framework/src/modules/dynamic_rewards/module.ts @@ -0,0 +1,250 @@ +/* + * Copyright © 2021 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 { objects } from '@liskhq/lisk-utils'; +import { validator } from '@liskhq/lisk-validator'; +import { BaseModule, ModuleInitArgs, ModuleMetadata } from '../base_module'; +import { DECIMAL_PERCENT_FACTOR, defaultConfig, EMPTY_BYTES } from './constants'; +import { + REWARD_NO_REDUCTION, + REWARD_REDUCTION_FACTOR_BFT, + REWARD_REDUCTION_MAX_PREVOTES, + REWARD_REDUCTION_SEED_REVEAL, +} from '../reward/constants'; +import { + DPoSMethod, + ModuleConfig, + ModuleConfigJSON, + RandomMethod, + TokenMethod, + ValidatorsMethod, +} from './types'; +import { BlockAfterExecuteContext, BlockHeader, ImmutableMethodContext } from '../../state_machine'; +import { DynamicRewardMethod } from './method'; +import { DynamicRewardEndpoint } from './endpoint'; +import { configSchema } from './schemas'; +import { RewardMintedEvent } from '../reward/events/reward_minted'; +import { EndOfRoundTimestampStore } from './stores/end_of_round_timestamp'; +import { + BlockAssets, + BlockExecuteContext, + GenesisBlockExecuteContext, +} from '../../state_machine/types'; +import { calculateDefaultReward } from '../reward/calculate_reward'; +import { BlockRewardsDataStore } from './stores/block_rewards'; +import { + getDefaultRewardAtHeightRequestSchema, + getDefaultRewardAtHeightResponseSchema, +} from '../reward/schemas'; + +export class DynamicRewardModule extends BaseModule { + public method = new DynamicRewardMethod(this.stores, this.events); + public configSchema = configSchema; + public endpoint = new DynamicRewardEndpoint(this.stores, this.offchainStores); + private _tokenMethod!: TokenMethod; + private _randomMethod!: RandomMethod; + private _validatorMethod!: ValidatorsMethod; + private _dposMethod!: DPoSMethod; + private _moduleConfig!: ModuleConfig; + + public constructor() { + super(); + this.stores.register(EndOfRoundTimestampStore, new EndOfRoundTimestampStore(this.name)); + this.stores.register(BlockRewardsDataStore, new BlockRewardsDataStore(this.name)); + this.events.register(RewardMintedEvent, new RewardMintedEvent(this.name)); + } + + public addDependencies( + tokenMethod: TokenMethod, + randomMethod: RandomMethod, + validatorMethod: ValidatorsMethod, + dposMethod: DPoSMethod, + ) { + this._tokenMethod = tokenMethod; + this._randomMethod = randomMethod; + this._validatorMethod = validatorMethod; + this._dposMethod = dposMethod; + } + + public metadata(): ModuleMetadata { + return { + endpoints: [ + { + name: this.endpoint.getDefaultRewardAtHeight.name, + request: getDefaultRewardAtHeightRequestSchema, + response: getDefaultRewardAtHeightResponseSchema, + }, + ], + commands: [], + events: this.events.values().map(v => ({ + name: v.name, + data: v.schema, + })), + assets: [], + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async init(args: ModuleInitArgs): Promise { + const { moduleConfig } = args; + const config = objects.mergeDeep( + {}, + { + ...defaultConfig, + tokenID: `${args.genesisConfig.chainID}00000000`, + }, + moduleConfig, + ); + validator.validate(configSchema, config); + + this._moduleConfig = { + ...config, + brackets: config.brackets.map(bracket => BigInt(bracket)), + tokenID: Buffer.from(config.tokenID, 'hex'), + }; + + this.method.init({ config: this._moduleConfig }); + + this.endpoint.init(this._moduleConfig); + } + + public async initGenesisState(context: GenesisBlockExecuteContext): Promise { + await this.stores + .get(EndOfRoundTimestampStore) + .set(context, EMPTY_BYTES, { timestamp: context.header.timestamp }); + } + + public async beforeTransactionsExecute(context: BlockExecuteContext): Promise { + const defaultReward = await this._getDefaultBlockReward( + context.getMethodContext(), + context.header, + ); + + await this.stores + .get(BlockRewardsDataStore) + .set(context, EMPTY_BYTES, { reward: defaultReward }); + } + + public async afterTransactionsExecute(context: BlockAfterExecuteContext): Promise { + const { reward: defaultReward } = await this.stores + .get(BlockRewardsDataStore) + .get(context, EMPTY_BYTES); + const [blockReward, reduction] = await this._getBlockRewardDeduction( + context.getMethodContext(), + context.header, + context.assets, + defaultReward, + ); + + if (blockReward !== BigInt(0)) { + await this._tokenMethod.mint( + context.getMethodContext(), + context.header.generatorAddress, + this._moduleConfig.tokenID, + blockReward, + ); + await this._dposMethod.updateSharedRewards( + context.getMethodContext(), + context.header.generatorAddress, + this._moduleConfig.tokenID, + blockReward, + ); + } + + const isEndOfRound = await this._dposMethod.isEndOfRound( + context.getMethodContext(), + context.header.height, + ); + if (isEndOfRound) { + await this.stores + .get(EndOfRoundTimestampStore) + .set(context, EMPTY_BYTES, { timestamp: context.header.timestamp }); + } + + this.events.get(RewardMintedEvent).log(context, context.header.generatorAddress, { + amount: blockReward, + reduction, + }); + } + + public async _getBlockRewardDeduction( + context: ImmutableMethodContext, + header: BlockHeader, + assets: BlockAssets, + defaultReward: bigint, + ): Promise<[bigint, number]> { + const isSeedRevealValid = await this._randomMethod.isSeedRevealValid( + context, + header.generatorAddress, + assets, + ); + if (!isSeedRevealValid) { + return [BigInt(0), REWARD_REDUCTION_SEED_REVEAL]; + } + + if (!header.impliesMaxPrevotes) { + return [defaultReward / REWARD_REDUCTION_FACTOR_BFT, REWARD_REDUCTION_MAX_PREVOTES]; + } + + return [defaultReward, REWARD_NO_REDUCTION]; + } + + public async _getDefaultBlockReward( + context: ImmutableMethodContext, + header: BlockHeader, + ): Promise { + const roundLength = this._dposMethod.getRoundLength(context); + const { timestamp } = await this.stores.get(EndOfRoundTimestampStore).get(context, EMPTY_BYTES); + + const generatorsMap = await this._validatorMethod.getGeneratorsBetweenTimestamps( + context, + timestamp, + header.timestamp, + ); + + const defaultReward = calculateDefaultReward(this._moduleConfig, header.height); + const minimalRewardActiveDelegates = + (defaultReward * BigInt(this._moduleConfig.factorMinimumRewardActiveDelegates)) / + DECIMAL_PERCENT_FACTOR; + if (Object.keys(generatorsMap).length >= roundLength) { + return minimalRewardActiveDelegates; + } + + const validatorsParams = await this._validatorMethod.getValidatorsParams(context); + let bftWeightSum = BigInt(0); + let bftValidator: typeof validatorsParams.validators[0] | undefined; + + for (const v of validatorsParams.validators) { + bftWeightSum += v.bftWeight; + if (v.address.equals(header.generatorAddress)) { + bftValidator = v; + } + } + if (!bftValidator) { + throw new Error('Invalid generator. Validator params does not include the validator.'); + } + const numberOfActiveDelegates = this._dposMethod.getNumberOfActiveDelegates(context); + const totalRewardActiveDelegates = defaultReward * BigInt(numberOfActiveDelegates); + const stakeRewardActiveDelegates = + totalRewardActiveDelegates - BigInt(numberOfActiveDelegates) * minimalRewardActiveDelegates; + if (bftValidator.bftWeight > BigInt(0)) { + return ( + minimalRewardActiveDelegates + + (bftValidator.bftWeight * stakeRewardActiveDelegates) / bftWeightSum + ); + } + + return defaultReward; + } +} diff --git a/framework/src/modules/dynamic_rewards/schemas.ts b/framework/src/modules/dynamic_rewards/schemas.ts new file mode 100644 index 00000000000..fe960fc0ff6 --- /dev/null +++ b/framework/src/modules/dynamic_rewards/schemas.ts @@ -0,0 +1,27 @@ +/* + * Copyright © 2021 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 { configSchema as rewardConfigSchema } from '../reward/schemas'; + +export const configSchema = { + $id: '/dynamicReward/config', + type: 'object', + properties: { + ...rewardConfigSchema.properties, + factorMinimumRewardActiveDelegates: { + type: 'integer', + minimum: 1, + }, + }, + required: [...rewardConfigSchema.required, 'factorMinimumRewardActiveDelegates'], +}; diff --git a/framework/src/modules/dynamic_rewards/stores/block_rewards.ts b/framework/src/modules/dynamic_rewards/stores/block_rewards.ts new file mode 100644 index 00000000000..e3184a8744d --- /dev/null +++ b/framework/src/modules/dynamic_rewards/stores/block_rewards.ts @@ -0,0 +1,34 @@ +/* + * 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 { BaseStore } from '../../base_store'; + +export interface BlockRewardsData { + reward: bigint; +} + +export const blockRewardsDataSchema = { + $id: '/dynamicRewards/blockRewardsData', + type: 'object', + properties: { + reward: { + dataType: 'uint64', + fieldNumber: 1, + }, + }, + required: ['reward'], +}; + +export class BlockRewardsDataStore extends BaseStore { + public schema = blockRewardsDataSchema; +} diff --git a/framework/src/modules/dynamic_rewards/stores/end_of_round_timestamp.ts b/framework/src/modules/dynamic_rewards/stores/end_of_round_timestamp.ts new file mode 100644 index 00000000000..7f116344e0d --- /dev/null +++ b/framework/src/modules/dynamic_rewards/stores/end_of_round_timestamp.ts @@ -0,0 +1,34 @@ +/* + * 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 { BaseStore } from '../../base_store'; + +export interface EndOfRoundTimestampData { + timestamp: number; +} + +export const endOfRoundTimestampSchema = { + $id: '/dynamicRewards/endOfRoundTimestamp', + type: 'object', + properties: { + timestamp: { + dataType: 'uint32', + fieldNumber: 1, + }, + }, + required: ['timestamp'], +}; + +export class EndOfRoundTimestampStore extends BaseStore { + public schema = endOfRoundTimestampSchema; +} diff --git a/framework/src/modules/dynamic_rewards/types.ts b/framework/src/modules/dynamic_rewards/types.ts new file mode 100644 index 00000000000..11bea713766 --- /dev/null +++ b/framework/src/modules/dynamic_rewards/types.ts @@ -0,0 +1,72 @@ +/* + * Copyright © 2021 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 { BlockAssets, MethodContext, ImmutableMethodContext } from '../../state_machine'; +import { Validator } from '../../state_machine/types'; +import { JSONObject } from '../../types'; +import { ModuleConfig as RewardModuleConfig } from '../reward/types'; + +export interface ModuleConfig extends RewardModuleConfig { + factorMinimumRewardActiveDelegates: number; +} + +export type ModuleConfigJSON = JSONObject; + +export interface TokenMethod { + mint: ( + methodContext: MethodContext, + address: Buffer, + id: Buffer, + amount: bigint, + ) => Promise; +} + +export interface RandomMethod { + isSeedRevealValid( + methodContext: ImmutableMethodContext, + generatorAddress: Buffer, + assets: BlockAssets, + ): Promise; +} + +export interface ValidatorsMethod { + getGeneratorsBetweenTimestamps( + methodContext: ImmutableMethodContext, + startTimestamp: number, + endTimestamp: number, + ): Promise>; + getValidatorsParams( + methodContext: ImmutableMethodContext, + ): Promise<{ + preCommitThreshold: bigint; + certificateThreshold: bigint; + validators: Validator[]; + }>; +} + +export interface DPoSMethod { + getRoundLength(methodContext: ImmutableMethodContext): number; + getNumberOfActiveDelegates(methodContext: ImmutableMethodContext): number; + updateSharedRewards( + methodContext: MethodContext, + generatorAddress: Buffer, + tokenID: Buffer, + reward: bigint, + ): Promise; + isEndOfRound(methodContext: ImmutableMethodContext, height: number): Promise; +} + +export interface DefaultReward { + reward: string; +} diff --git a/framework/src/modules/reward/calculate_reward.ts b/framework/src/modules/reward/calculate_reward.ts index d581f9fc7de..009c0d0fba5 100644 --- a/framework/src/modules/reward/calculate_reward.ts +++ b/framework/src/modules/reward/calculate_reward.ts @@ -12,20 +12,19 @@ * Removal or modification of this copyright notice is prohibited. */ -import { CalculateDefaultRewardArgs } from './types'; +import { ModuleConfig } from './types'; -export const calculateDefaultReward = (args: CalculateDefaultRewardArgs): bigint => { - const { height, distance, offset, brackets } = args; - - if (height < offset) { +export const calculateDefaultReward = (config: ModuleConfig, height: number): bigint => { + if (height < config.offset) { return BigInt(0); } - const rewardDistance = Math.floor(distance); - const location = Math.trunc((height - offset) / rewardDistance); - const lastBracket = brackets[brackets.length - 1]; + const rewardDistance = Math.floor(config.distance); + const location = Math.trunc((height - config.offset) / rewardDistance); + const lastBracket = config.brackets[config.brackets.length - 1]; - const bracket = location > brackets.length - 1 ? brackets.lastIndexOf(lastBracket) : location; + const bracket = + location > config.brackets.length - 1 ? config.brackets.lastIndexOf(lastBracket) : location; - return brackets[bracket]; + return config.brackets[bracket]; }; diff --git a/framework/src/modules/reward/constants.ts b/framework/src/modules/reward/constants.ts index f6006a16070..6a5516a44aa 100644 --- a/framework/src/modules/reward/constants.ts +++ b/framework/src/modules/reward/constants.ts @@ -23,7 +23,7 @@ export const TOKEN_ID_LSK_MAINCHAIN = { export const REWARD_NO_REDUCTION = 0; export const REWARD_REDUCTION_SEED_REVEAL = 1; export const REWARD_REDUCTION_MAX_PREVOTES = 2; -export const REWARD_REDUCTION_FACTOR_BFT = 4; +export const REWARD_REDUCTION_FACTOR_BFT = BigInt(4); export const defaultConfig = { tokenID: '0000000000000000', diff --git a/framework/src/modules/reward/endpoint.ts b/framework/src/modules/reward/endpoint.ts index 9bf633ef648..d811173741c 100644 --- a/framework/src/modules/reward/endpoint.ts +++ b/framework/src/modules/reward/endpoint.ts @@ -15,17 +15,13 @@ import { ModuleEndpointContext } from '../../types'; import { BaseEndpoint } from '../base_endpoint'; import { calculateDefaultReward } from './calculate_reward'; -import { DefaultReward, EndpointInitArgs } from './types'; +import { DefaultReward, ModuleConfig } from './types'; export class RewardEndpoint extends BaseEndpoint { - private _brackets!: ReadonlyArray; - private _offset!: number; - private _distance!: number; - - public init(args: EndpointInitArgs) { - this._brackets = args.config.brackets; - this._offset = args.config.offset; - this._distance = args.config.distance; + protected _config!: ModuleConfig; + + public init(config: ModuleConfig) { + this._config = config; } public getDefaultRewardAtHeight(ctx: ModuleEndpointContext): DefaultReward { @@ -39,12 +35,7 @@ export class RewardEndpoint extends BaseEndpoint { throw new Error('Parameter height cannot be smaller than 0.'); } - const reward = calculateDefaultReward({ - height, - brackets: this._brackets, - distance: this._distance, - offset: this._offset, - }); + const reward = calculateDefaultReward(this._config, height); return { reward: reward.toString() }; } diff --git a/framework/src/modules/reward/method.ts b/framework/src/modules/reward/method.ts index d79b35a9c15..793969c037f 100644 --- a/framework/src/modules/reward/method.ts +++ b/framework/src/modules/reward/method.ts @@ -21,18 +21,18 @@ import { REWARD_REDUCTION_MAX_PREVOTES, REWARD_REDUCTION_SEED_REVEAL, } from './constants'; -import { MethodInitArgs, RandomMethod } from './types'; +import { ModuleConfig, RandomMethod } from './types'; + +interface MethodInitArgs { + config: ModuleConfig; +} export class RewardMethod extends BaseMethod { private _randomMethod!: RandomMethod; - private _brackets!: ReadonlyArray; - private _offset!: number; - private _distance!: number; + private _config!: ModuleConfig; public init(args: MethodInitArgs) { - this._brackets = args.config.brackets; - this._offset = args.config.offset; - this._distance = args.config.distance; + this._config = args.config; } public addDependencies(randomMethod: RandomMethod): void { @@ -44,12 +44,7 @@ export class RewardMethod extends BaseMethod { header: BlockHeader, assets: BlockAssets, ): Promise<[bigint, number]> { - const defaultReward = calculateDefaultReward({ - height: header.height, - brackets: this._brackets, - distance: this._distance, - offset: this._offset, - }); + const defaultReward = calculateDefaultReward(this._config, header.height); if (defaultReward === BigInt(0)) { return [defaultReward, REWARD_NO_REDUCTION]; } diff --git a/framework/src/modules/reward/module.ts b/framework/src/modules/reward/module.ts index 77fca934928..850eb27e73d 100644 --- a/framework/src/modules/reward/module.ts +++ b/framework/src/modules/reward/module.ts @@ -16,7 +16,7 @@ import { objects } from '@liskhq/lisk-utils'; import { validator } from '@liskhq/lisk-validator'; import { BaseModule, ModuleInitArgs, ModuleMetadata } from '../base_module'; import { defaultConfig } from './constants'; -import { ModuleConfig, RandomMethod, TokenMethod } from './types'; +import { ModuleConfig, ModuleConfigJSON, RandomMethod, TokenMethod } from './types'; import { BlockAfterExecuteContext } from '../../state_machine'; import { RewardMethod } from './method'; import { RewardEndpoint } from './endpoint'; @@ -33,7 +33,6 @@ export class RewardModule extends BaseModule { public endpoint = new RewardEndpoint(this.stores, this.offchainStores); private _tokenMethod!: TokenMethod; private _randomMethod!: RandomMethod; - private _tokenID!: Buffer; private _moduleConfig!: ModuleConfig; public constructor() { @@ -69,26 +68,19 @@ export class RewardModule extends BaseModule { public async init(args: ModuleInitArgs): Promise { const { moduleConfig } = args; const config = objects.mergeDeep({}, defaultConfig, moduleConfig); - validator.validate(configSchema, config); + validator.validate(configSchema, config); - this._moduleConfig = (config as unknown) as ModuleConfig; - this._tokenID = Buffer.from(this._moduleConfig.tokenID, 'hex'); + this._moduleConfig = { + ...config, + brackets: config.brackets.map(bracket => BigInt(bracket)), + tokenID: Buffer.from(config.tokenID, 'hex'), + }; this.method.init({ - config: { - brackets: this._moduleConfig.brackets.map(bracket => BigInt(bracket)), - offset: this._moduleConfig.offset, - distance: this._moduleConfig.distance, - }, + config: this._moduleConfig, }); - this.endpoint.init({ - config: { - brackets: this._moduleConfig.brackets.map(bracket => BigInt(bracket)), - offset: this._moduleConfig.offset, - distance: this._moduleConfig.distance, - }, - }); + this.endpoint.init(this._moduleConfig); } public async afterTransactionsExecute(context: BlockAfterExecuteContext): Promise { @@ -105,7 +97,7 @@ export class RewardModule extends BaseModule { await this._tokenMethod.mint( context.getMethodContext(), context.header.generatorAddress, - this._tokenID, + this._moduleConfig.tokenID, blockReward, ); } diff --git a/framework/src/modules/reward/types.ts b/framework/src/modules/reward/types.ts index f33c2df6d9d..cca5a69f589 100644 --- a/framework/src/modules/reward/types.ts +++ b/framework/src/modules/reward/types.ts @@ -13,14 +13,17 @@ */ import { BlockAssets, MethodContext, ImmutableMethodContext } from '../../state_machine'; +import { JSONObject } from '../../types'; export interface ModuleConfig { - tokenID: string; - brackets: ReadonlyArray; + tokenID: Buffer; + brackets: ReadonlyArray; offset: number; distance: number; } +export type ModuleConfigJSON = JSONObject; + export interface TokenMethod { mint: ( methodContext: MethodContext, @@ -45,26 +48,3 @@ export interface BFTMethod { export interface DefaultReward { reward: string; } - -export interface EndpointInitArgs { - config: { - brackets: ReadonlyArray; - offset: number; - distance: number; - }; -} - -export interface MethodInitArgs { - config: { - brackets: ReadonlyArray; - offset: number; - distance: number; - }; -} - -export interface CalculateDefaultRewardArgs { - brackets: ReadonlyArray; - offset: number; - distance: number; - height: number; -} diff --git a/framework/test/unit/modules/dynamic_rewards/module.spec.ts b/framework/test/unit/modules/dynamic_rewards/module.spec.ts new file mode 100644 index 00000000000..f774f2a4545 --- /dev/null +++ b/framework/test/unit/modules/dynamic_rewards/module.spec.ts @@ -0,0 +1,387 @@ +/* + * Copyright © 2021 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 { BlockHeader } from '@liskhq/lisk-chain'; +import { utils } from '@liskhq/lisk-cryptography'; +import { + createBlockContext, + createBlockHeaderWithDefaults, + createGenesisBlockContext, + InMemoryPrefixedStateDB, +} from '../../../../src/testing'; +import { RewardMintedEvent } from '../../../../src/modules/reward/events/reward_minted'; +import { DynamicRewardModule } from '../../../../src/modules/dynamic_rewards'; +import { + DPoSMethod, + RandomMethod, + TokenMethod, + ValidatorsMethod, +} from '../../../../src/modules/dynamic_rewards/types'; +import { + DECIMAL_PERCENT_FACTOR, + defaultConfig, + EMPTY_BYTES, +} from '../../../../src/modules/dynamic_rewards/constants'; +import { + BlockAfterExecuteContext, + BlockExecuteContext, + GenesisBlockExecuteContext, +} from '../../../../src'; +import { PrefixedStateReadWriter } from '../../../../src/state_machine/prefixed_state_read_writer'; +import { EndOfRoundTimestampStore } from '../../../../src/modules/dynamic_rewards/stores/end_of_round_timestamp'; +import { BlockRewardsDataStore } from '../../../../src/modules/dynamic_rewards/stores/block_rewards'; +import { + REWARD_NO_REDUCTION, + REWARD_REDUCTION_MAX_PREVOTES, + REWARD_REDUCTION_SEED_REVEAL, +} from '../../../../src/modules/reward/constants'; + +describe('DynamicRewardModule', () => { + const defaultRoundLength = 103; + const defaultNumberOfActiveDelegates = 101; + + let rewardModule: DynamicRewardModule; + let tokenMethod: TokenMethod; + let randomMethod: RandomMethod; + let validatorsMethod: ValidatorsMethod; + let dposMethod: DPoSMethod; + + beforeEach(async () => { + rewardModule = new DynamicRewardModule(); + await rewardModule.init({ + generatorConfig: {}, + genesisConfig: { chainID: '00000000' } as any, + moduleConfig: {}, + }); + tokenMethod = { mint: jest.fn() }; + randomMethod = { isSeedRevealValid: jest.fn().mockReturnValue(true) }; + validatorsMethod = { + getGeneratorsBetweenTimestamps: jest.fn(), + getValidatorsParams: jest.fn(), + }; + dposMethod = { + getNumberOfActiveDelegates: jest.fn().mockReturnValue(defaultNumberOfActiveDelegates), + getRoundLength: jest.fn().mockReturnValue(defaultRoundLength), + updateSharedRewards: jest.fn(), + isEndOfRound: jest.fn(), + }; + rewardModule.addDependencies(tokenMethod, randomMethod, validatorsMethod, dposMethod); + }); + + describe('init', () => { + it('should initialize config with default value when module config is empty', async () => { + rewardModule = new DynamicRewardModule(); + await expect( + rewardModule.init({ + genesisConfig: { chainID: '00000000' } as any, + moduleConfig: {}, + generatorConfig: {}, + }), + ).toResolve(); + + expect(rewardModule['_moduleConfig']).toEqual({ + ...defaultConfig, + brackets: defaultConfig.brackets.map(b => BigInt(b)), + tokenID: Buffer.from(defaultConfig.tokenID, 'hex'), + }); + }); + + it('should initialize config with given value', async () => { + rewardModule = new DynamicRewardModule(); + await expect( + rewardModule.init({ + genesisConfig: { chainID: '00000000' } as any, + moduleConfig: { offset: 1000 }, + generatorConfig: {}, + }), + ).toResolve(); + + expect(rewardModule['_moduleConfig'].offset).toEqual(1000); + }); + + it('should not initialize config with invalid value for tokenID', async () => { + rewardModule = new DynamicRewardModule(); + try { + await rewardModule.init({ + genesisConfig: { chainID: '00000000' } as any, + moduleConfig: { + tokenID: '00000000000000000', + }, + generatorConfig: {}, + }); + } catch (error: any) { + // eslint-disable-next-line jest/no-try-expect + expect(error.message).toInclude("Property '.tokenID' must NOT have more than 16 character"); + } + }); + }); + + describe('initGenesisState', () => { + let blockHeader: BlockHeader; + let blockExecuteContext: GenesisBlockExecuteContext; + let stateStore: PrefixedStateReadWriter; + + beforeEach(() => { + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + blockHeader = createBlockHeaderWithDefaults({ timestamp: 1234 }); + blockExecuteContext = createGenesisBlockContext({ + header: blockHeader, + stateStore, + }).createInitGenesisStateContext(); + }); + + it('should store genesis header timestamp', async () => { + await rewardModule.initGenesisState(blockExecuteContext); + const { timestamp } = await rewardModule.stores + .get(EndOfRoundTimestampStore) + .get(blockExecuteContext, EMPTY_BYTES); + + expect(timestamp).toEqual(1234); + }); + }); + + describe('beforeTransactionsExecute', () => { + let blockExecuteContext: BlockExecuteContext; + let generatorAddress: Buffer; + let standbyValidatorAddress: Buffer; + let stateStore: PrefixedStateReadWriter; + + const activeDelegate = 4; + const minimumReward = + (BigInt(defaultConfig.brackets[0]) * + BigInt(defaultConfig.factorMinimumRewardActiveDelegates)) / + DECIMAL_PERCENT_FACTOR; + const totalRewardActiveDelegate = BigInt(defaultConfig.brackets[0]) * BigInt(activeDelegate); + const ratioReward = totalRewardActiveDelegate - minimumReward * BigInt(activeDelegate); + + beforeEach(async () => { + generatorAddress = utils.getRandomBytes(20); + standbyValidatorAddress = utils.getRandomBytes(20); + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + const blockHeader = createBlockHeaderWithDefaults({ + height: defaultConfig.offset, + generatorAddress, + }); + blockExecuteContext = createBlockContext({ + stateStore, + header: blockHeader, + }).getBlockExecuteContext(); + await rewardModule.stores + .get(EndOfRoundTimestampStore) + .set(blockExecuteContext, EMPTY_BYTES, { timestamp: blockHeader.timestamp - 100000 }); + const validators = [ + { address: generatorAddress, bftWeight: BigInt(20) }, + { address: utils.getRandomBytes(20), bftWeight: BigInt(30) }, + { address: utils.getRandomBytes(20), bftWeight: BigInt(40) }, + { address: utils.getRandomBytes(20), bftWeight: BigInt(10) }, + { address: standbyValidatorAddress, bftWeight: BigInt(0) }, + ]; + + (validatorsMethod.getValidatorsParams as jest.Mock).mockResolvedValue({ validators }); + (dposMethod.getNumberOfActiveDelegates as jest.Mock).mockReturnValue(activeDelegate); + }); + + it('should store minimal reward for active delegates when full round is forged', async () => { + // Round is already completed once + const generatorMap = new Array(defaultRoundLength).fill(0).reduce(prev => { + // eslint-disable-next-line no-param-reassign + prev[utils.getRandomBytes(20).toString('binary')] = 1; + return prev; + }, {}); + (validatorsMethod.getGeneratorsBetweenTimestamps as jest.Mock).mockResolvedValue( + generatorMap, + ); + + await rewardModule.beforeTransactionsExecute(blockExecuteContext); + + const { reward } = await rewardModule.stores + .get(BlockRewardsDataStore) + .get(blockExecuteContext, EMPTY_BYTES); + + expect(reward).toEqual(minimumReward); + }); + + it('should store reward based on ratio when BFTWeight is positive', async () => { + // Round not finished + const generatorMap = new Array(1).fill(0).reduce(prev => { + // eslint-disable-next-line no-param-reassign + prev[utils.getRandomBytes(20).toString('binary')] = 1; + return prev; + }, {}); + (validatorsMethod.getGeneratorsBetweenTimestamps as jest.Mock).mockResolvedValue( + generatorMap, + ); + + await rewardModule.beforeTransactionsExecute(blockExecuteContext); + + const { reward } = await rewardModule.stores + .get(BlockRewardsDataStore) + .get(blockExecuteContext, EMPTY_BYTES); + + // generatorAddress has 20% of total weight + expect(reward).toEqual(minimumReward + ratioReward / BigInt(5)); + }); + + it('should store default reward when weight is zero', async () => { + // Round not finished + const generatorMap = new Array(1).fill(0).reduce(prev => { + // eslint-disable-next-line no-param-reassign + prev[utils.getRandomBytes(20).toString('binary')] = 1; + return prev; + }, {}); + (validatorsMethod.getGeneratorsBetweenTimestamps as jest.Mock).mockResolvedValue( + generatorMap, + ); + + const blockHeader = createBlockHeaderWithDefaults({ + height: defaultConfig.offset, + generatorAddress: standbyValidatorAddress, + }); + blockExecuteContext = createBlockContext({ + stateStore, + header: blockHeader, + }).getBlockExecuteContext(); + + await rewardModule.beforeTransactionsExecute(blockExecuteContext); + + const { reward } = await rewardModule.stores + .get(BlockRewardsDataStore) + .get(blockExecuteContext, EMPTY_BYTES); + + expect(reward).toEqual(BigInt(defaultConfig.brackets[0])); + }); + }); + + describe('afterTransactionsExecute', () => { + let blockExecuteContext: BlockAfterExecuteContext; + let stateStore: PrefixedStateReadWriter; + + const defaultReward = BigInt(500000000); + + beforeEach(async () => { + jest.spyOn(rewardModule.events.get(RewardMintedEvent), 'log'); + stateStore = new PrefixedStateReadWriter(new InMemoryPrefixedStateDB()); + const blockHeader = createBlockHeaderWithDefaults({ height: defaultConfig.offset }); + blockExecuteContext = createBlockContext({ + stateStore, + header: blockHeader, + }).getBlockAfterExecuteContext(); + await rewardModule.stores + .get(BlockRewardsDataStore) + .set(blockExecuteContext, EMPTY_BYTES, { reward: defaultReward }); + }); + + it('should return zero reward with seed reveal reduction when seed reveal is invalid', async () => { + (randomMethod.isSeedRevealValid as jest.Mock).mockResolvedValue(false); + + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + expect(rewardModule.events.get(RewardMintedEvent).log).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + { amount: BigInt(0), reduction: REWARD_REDUCTION_SEED_REVEAL }, + ); + }); + + it('should return quarter deducted reward when header does not imply max prevotes', async () => { + const blockHeader = createBlockHeaderWithDefaults({ + height: defaultConfig.offset, + impliesMaxPrevotes: false, + }); + blockExecuteContext = createBlockContext({ + stateStore, + header: blockHeader, + }).getBlockAfterExecuteContext(); + + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + expect(rewardModule.events.get(RewardMintedEvent).log).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + { + amount: BigInt(defaultConfig.brackets[0]) / BigInt(4), + reduction: REWARD_REDUCTION_MAX_PREVOTES, + }, + ); + }); + + it('should return full reward when header and assets are valid', async () => { + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + expect(rewardModule.events.get(RewardMintedEvent).log).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + { amount: BigInt(defaultConfig.brackets[0]), reduction: REWARD_NO_REDUCTION }, + ); + }); + + it('should mint the token and update shared reward when reward is non zero', async () => { + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + expect(rewardModule.events.get(RewardMintedEvent).log).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + { amount: BigInt(defaultConfig.brackets[0]), reduction: REWARD_NO_REDUCTION }, + ); + + expect(tokenMethod.mint).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + rewardModule['_moduleConfig'].tokenID, + BigInt(defaultConfig.brackets[0]), + ); + expect(dposMethod.updateSharedRewards).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + rewardModule['_moduleConfig'].tokenID, + BigInt(defaultConfig.brackets[0]), + ); + }); + + it('should not update shared reward and mint when reward is non zero', async () => { + (randomMethod.isSeedRevealValid as jest.Mock).mockResolvedValue(false); + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + expect(rewardModule.events.get(RewardMintedEvent).log).toHaveBeenCalledWith( + expect.anything(), + blockExecuteContext.header.generatorAddress, + { amount: BigInt(0), reduction: REWARD_REDUCTION_SEED_REVEAL }, + ); + + expect(tokenMethod.mint).not.toHaveBeenCalled(); + expect(dposMethod.updateSharedRewards).not.toHaveBeenCalled(); + }); + + it('should store timestamp when end of round', async () => { + const timestamp = 123456789; + const blockHeader = createBlockHeaderWithDefaults({ + height: defaultConfig.offset, + timestamp, + }); + blockExecuteContext = createBlockContext({ + stateStore, + header: blockHeader, + }).getBlockAfterExecuteContext(); + + (dposMethod.isEndOfRound as jest.Mock).mockResolvedValue(true); + + await rewardModule.afterTransactionsExecute(blockExecuteContext); + + const { timestamp: updatedTimestamp } = await rewardModule.stores + .get(EndOfRoundTimestampStore) + .get(blockExecuteContext, EMPTY_BYTES); + + expect(updatedTimestamp).toEqual(timestamp); + }); + }); +}); diff --git a/framework/test/unit/modules/reward/reward_module.spec.ts b/framework/test/unit/modules/reward/reward_module.spec.ts index fa4f1d8394d..0fb30b5be71 100644 --- a/framework/test/unit/modules/reward/reward_module.spec.ts +++ b/framework/test/unit/modules/reward/reward_module.spec.ts @@ -23,7 +23,7 @@ import { RewardMintedEvent } from '../../../../src/modules/reward/events/reward_ describe('RewardModule', () => { const genesisConfig: any = {}; - const moduleConfig: any = { + const moduleConfig = { distance: 3000000, offset: 2160, brackets: [ @@ -56,7 +56,11 @@ describe('RewardModule', () => { rewardModule.init({ genesisConfig: {} as any, moduleConfig: {}, generatorConfig: {} }), ).toResolve(); - expect(rewardModule['_moduleConfig']).toEqual({ ...moduleConfig }); + expect(rewardModule['_moduleConfig']).toEqual({ + ...moduleConfig, + brackets: moduleConfig.brackets.map(b => BigInt(b)), + tokenID: Buffer.from(moduleConfig.tokenID, 'hex'), + }); }); it('should initialize config with given value', async () => {