From 7dbf88870faeed61d550767eb69aae6719fa30f8 Mon Sep 17 00:00:00 2001 From: Suraj Auwal Date: Fri, 16 Jul 2021 13:36:39 +0100 Subject: [PATCH] Masternode controller (#204) * WIP [masternode controller] * Masternode.spec.ts * Masternode.controller.e2e.ts * Cleanup and CI fixes * Code governace fix and minor fixes * clean up and improvements * Minor fixes * Minor fixes * Fix ci failure * Implemented quested advise on PR * Minor fixes * Minor fixes * updated masternode tests to use genesis masternodes * Improved code better testing naming * Apply suggestions from code review * Added more tests assertations Co-authored-by: Fuxing Loh <4266087+fuxingloh@users.noreply.github.com> --- .../__tests__/api/masternode.test.ts | 120 ++++++++++++++++++ .../whale-api-client/src/api/masternode.ts | 66 ++++++++++ packages/whale-api-client/src/index.ts | 1 + .../whale-api-client/src/whale.api.client.ts | 2 + src/module.api/_module.ts | 4 +- src/module.api/masternode.controller.e2e.ts | 73 +++++++++++ src/module.api/masternode.controller.spec.ts | 84 ++++++++++++ src/module.api/masternode.controller.ts | 78 ++++++++++++ 8 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/whale-api-client/__tests__/api/masternode.test.ts create mode 100644 packages/whale-api-client/src/api/masternode.ts create mode 100644 src/module.api/masternode.controller.e2e.ts create mode 100644 src/module.api/masternode.controller.spec.ts create mode 100644 src/module.api/masternode.controller.ts diff --git a/packages/whale-api-client/__tests__/api/masternode.test.ts b/packages/whale-api-client/__tests__/api/masternode.test.ts new file mode 100644 index 000000000..e130c0ce0 --- /dev/null +++ b/packages/whale-api-client/__tests__/api/masternode.test.ts @@ -0,0 +1,120 @@ +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { StubWhaleApiClient } from '../stub.client' +import { StubService } from '../stub.service' +import { WhaleApiClient, WhaleApiException } from '../../src' + +let container: MasterNodeRegTestContainer +let service: StubService +let client: WhaleApiClient + +beforeAll(async () => { + container = new MasterNodeRegTestContainer() + service = new StubService(container) + client = new StubWhaleApiClient(service) + + await container.start() + await container.waitForReady() + await service.start() +}) + +afterAll(async () => { + try { + await service.stop() + } finally { + await container.stop() + } +}) + +describe('list', () => { + it('should list masternodes', async () => { + const data = await client.masternodes.list() + expect(Object.keys(data[0]).length).toStrictEqual(7) + expect(data.hasNext).toStrictEqual(false) + expect(data.nextToken).toStrictEqual(undefined) + + expect(data[0]).toStrictEqual({ + id: '03280abd3d3ae8dc294c1a572cd7912c3c3e53044943eac62c2f6c4687c87f10', + state: 'ENABLED', + mintedBlocks: 0, + owner: { address: 'bcrt1qyeuu9rvq8a67j86pzvh5897afdmdjpyankp4mu' }, + operator: { address: 'bcrt1qurwyhta75n2g75u2u5nds9p6w9v62y8wr40d2r' }, + creation: { height: 0 }, + resign: { + tx: '0000000000000000000000000000000000000000000000000000000000000000', + height: -1 + } + }) + }) + + it('should list masternodes with pagination', async () => { + const first = await client.masternodes.list(4) + expect(first.length).toStrictEqual(4) + expect(first.hasNext).toStrictEqual(true) + expect(first.nextToken).toStrictEqual(first[3].id) + + const next = await client.paginate(first) + expect(next.length).toStrictEqual(4) + expect(next.hasNext).toStrictEqual(true) + expect(next.nextToken).toStrictEqual(next[3].id) + + const last = await client.paginate(next) + expect(last.length).toStrictEqual(0) + expect(last.hasNext).toStrictEqual(false) + expect(last.nextToken).toStrictEqual(undefined) + }) +}) + +describe('get', () => { + it('should get masternode', async () => { + // get a masternode from list + const masternode = (await client.masternodes.list(1))[0] + + const data = await client.masternodes.get(masternode.id) + expect(Object.keys(data).length).toStrictEqual(7) + expect(data).toStrictEqual({ + id: masternode.id, + state: masternode.state, + mintedBlocks: masternode.mintedBlocks, + owner: { address: masternode.owner.address }, + operator: { address: masternode.operator.address }, + creation: { height: masternode.creation.height }, + resign: { + tx: masternode.resign.tx, + height: masternode.resign.height + } + }) + }) + + it('should fail due to non-existent masternode', async () => { + expect.assertions(2) + const id = '8d4d987dee688e400a0cdc899386f243250d3656d802231755ab4d28178c9816' + try { + await client.masternodes.get(id) + } catch (err) { + expect(err).toBeInstanceOf(WhaleApiException) + expect(err.error).toStrictEqual({ + code: 404, + type: 'NotFound', + at: expect.any(Number), + message: 'Unable to find masternode', + url: `/v0/regtest/masternodes/${id}` + }) + } + }) + + it('should fail and throw an error with malformed id', async () => { + expect.assertions(2) + try { + await client.masternodes.get('sdh183') + } catch (err) { + expect(err).toBeInstanceOf(WhaleApiException) + expect(err.error).toStrictEqual({ + code: 400, + type: 'BadRequest', + at: expect.any(Number), + message: "RpcApiError: 'masternode id must be of length 64 (not 6, for 'sdh183')', code: -8, method: getmasternode", + url: '/v0/regtest/masternodes/sdh183' + }) + } + }) +}) diff --git a/packages/whale-api-client/src/api/masternode.ts b/packages/whale-api-client/src/api/masternode.ts new file mode 100644 index 000000000..57d0bf8fc --- /dev/null +++ b/packages/whale-api-client/src/api/masternode.ts @@ -0,0 +1,66 @@ +import { WhaleApiClient } from '../whale.api.client' +import { ApiPagedResponse } from '../whale.api.response' + +/** + * DeFi whale endpoint for masternode related services. + */ +export class Masternodes { + constructor (private readonly client: WhaleApiClient) { + } + + /** + * Get list of masternodes. + * + * @param {number} size masternodes size to query + * @param {string} next set of masternodes to get + * @return {Promise>} + */ + async list (size: number = 30, next?: string): Promise> { + return await this.client.requestList('GET', 'masternodes', size, next) + } + + /** + * Get information about a masternode with given id. + * + * @param {string} id masternode id to get + * @return {Promise} + */ + async get (id: string): Promise { + return await this.client.requestData('GET', `masternodes/${id}`) + } +} + +/** + * Masternode data + */ +export interface MasternodeData { + id: string + state: MasternodeState + mintedBlocks: number + owner: { + address: string + } + operator: { + address: string + } + creation: { + height: number + } + resign: { + tx: string + height: number + } +} + +/** + * Masternode state + */ +export enum MasternodeState { + PRE_ENABLED = 'PRE_ENABLED', + ENABLED = 'ENABLED', + PRE_RESIGNED = 'PRE_RESIGNED', + RESIGNED = 'RESIGNED', + PRE_BANNED = 'PRE_BANNED', + BANNED = 'BANNED', + UNKNOWN = 'UNKNOWN' +} diff --git a/packages/whale-api-client/src/index.ts b/packages/whale-api-client/src/index.ts index da110cdd7..89ec316ae 100644 --- a/packages/whale-api-client/src/index.ts +++ b/packages/whale-api-client/src/index.ts @@ -5,6 +5,7 @@ export * as poolpair from './api/poolpair' export * as rpc from './api/rpc' export * as transactions from './api/transactions' export * as tokens from './api/tokens' +export * as masternodes from './api/masternode' export * from './whale.api.client' export * from './whale.api.response' diff --git a/packages/whale-api-client/src/whale.api.client.ts b/packages/whale-api-client/src/whale.api.client.ts index ddf8be0f4..650ad57bf 100644 --- a/packages/whale-api-client/src/whale.api.client.ts +++ b/packages/whale-api-client/src/whale.api.client.ts @@ -8,6 +8,7 @@ import { PoolPair } from './api/poolpair' import { Rpc } from './api/rpc' import { Transactions } from './api/transactions' import { Tokens } from './api/tokens' +import { Masternodes } from './api/masternode' /** * WhaleApiClient Options @@ -58,6 +59,7 @@ export class WhaleApiClient { public readonly rpc = new Rpc(this) public readonly transactions = new Transactions(this) public readonly tokens = new Tokens(this) + public readonly masternodes = new Masternodes(this) constructor ( private readonly options: WhaleApiClientOptions diff --git a/src/module.api/_module.ts b/src/module.api/_module.ts index dc38a6b0f..12591f247 100644 --- a/src/module.api/_module.ts +++ b/src/module.api/_module.ts @@ -11,6 +11,7 @@ import { NetworkGuard } from '@src/module.api/guards/network.guard' import { ExceptionInterceptor } from '@src/module.api/interceptors/exception.interceptor' import { ResponseInterceptor } from '@src/module.api/interceptors/response.interceptor' import { TokensController } from '@src/module.api/token.controller' +import { MasternodesController } from '@src/module.api/masternode.controller' /** * Exposed ApiModule for public interfacing @@ -23,7 +24,8 @@ import { TokensController } from '@src/module.api/token.controller' ActuatorController, TransactionsController, TokensController, - PoolPairController + PoolPairController, + MasternodesController ], providers: [ { provide: APP_PIPE, useClass: ApiValidationPipe }, diff --git a/src/module.api/masternode.controller.e2e.ts b/src/module.api/masternode.controller.e2e.ts new file mode 100644 index 000000000..4e2ee9481 --- /dev/null +++ b/src/module.api/masternode.controller.e2e.ts @@ -0,0 +1,73 @@ +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { NestFastifyApplication } from '@nestjs/platform-fastify' +import { createTestingApp, stopTestingApp } from '@src/e2e.module' +import { NotFoundException } from '@nestjs/common' +import { MasternodesController } from '@src/module.api/masternode.controller' + +const container = new MasterNodeRegTestContainer() +let app: NestFastifyApplication +let controller: MasternodesController + +beforeAll(async () => { + await container.start() + await container.waitForReady() + + app = await createTestingApp(container) + controller = app.get(MasternodesController) +}) + +afterAll(async () => { + await stopTestingApp(container, app) +}) + +describe('list', () => { + it('should list masternodes', async () => { + const result = await controller.list({ size: 4 }) + expect(result.data.length).toStrictEqual(4) + expect(Object.keys(result.data[0]).length).toStrictEqual(7) + }) + + it('should list masternodes with pagination', async () => { + const first = await controller.list({ size: 4 }) + expect(first.data.length).toStrictEqual(4) + + const next = await controller.list({ + size: 4, + next: first.page?.next + }) + expect(next.data.length).toStrictEqual(4) + expect(next.page?.next).toStrictEqual(next.data[3].id) + + const last = await controller.list({ + size: 4, + next: next.page?.next + }) + expect(last.data.length).toStrictEqual(0) + expect(last.page).toStrictEqual(undefined) + }) +}) + +describe('get', () => { + it('should get a masternode with id', async () => { + // get a masternode from list + const masternode = (await controller.list({ size: 1 })).data[0] + + const result = await controller.get(masternode.id) + expect(Object.keys(result).length).toStrictEqual(7) + expect(result).toStrictEqual(masternode) + }) + + it('should fail due to non-existent masternode', async () => { + expect.assertions(2) + try { + await controller.get('8d4d987dee688e400a0cdc899386f243250d3656d802231755ab4d28178c9816') + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException) + expect(err.response).toStrictEqual({ + statusCode: 404, + message: 'Unable to find masternode', + error: 'Not Found' + }) + } + }) +}) diff --git a/src/module.api/masternode.controller.spec.ts b/src/module.api/masternode.controller.spec.ts new file mode 100644 index 000000000..69f337720 --- /dev/null +++ b/src/module.api/masternode.controller.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' +import { MasternodesController } from '@src/module.api/masternode.controller' +import { CacheModule, NotFoundException } from '@nestjs/common' +import { DeFiDCache } from './cache/defid.cache' + +const container = new MasterNodeRegTestContainer() +let controller: MasternodesController + +beforeAll(async () => { + await container.start() + await container.waitForReady() + const client = new JsonRpcClient(await container.getCachedRpcUrl()) + + const app: TestingModule = await Test.createTestingModule({ + imports: [ + CacheModule.register() + ], + controllers: [MasternodesController], + providers: [ + { provide: JsonRpcClient, useValue: client }, + DeFiDCache + ] + }).compile() + + controller = app.get(MasternodesController) +}) + +afterAll(async () => { + await container.stop() +}) + +describe('list', () => { + it('should list masternodes', async () => { + const result = await controller.list({ size: 4 }) + expect(result.data.length).toStrictEqual(4) + expect(Object.keys(result.data[0]).length).toStrictEqual(7) + }) + + it('should list masternodes with pagination', async () => { + const first = await controller.list({ size: 4 }) + expect(first.data.length).toStrictEqual(4) + + const next = await controller.list({ + size: 4, + next: first.page?.next + }) + expect(next.data.length).toStrictEqual(4) + expect(next.page?.next).toStrictEqual(next.data[3].id) + + const last = await controller.list({ + size: 4, + next: next.page?.next + }) + expect(last.data.length).toStrictEqual(0) + expect(last.page).toStrictEqual(undefined) + }) +}) + +describe('get', () => { + it('should get a masternode with id', async () => { + // get a masternode from list + const masternode = (await controller.list({ size: 1 })).data[0] + + const result = await controller.get(masternode.id) + expect(Object.keys(result).length).toStrictEqual(7) + expect(result).toStrictEqual(masternode) + }) + + it('should fail due to non-existent masternode', async () => { + expect.assertions(2) + try { + await controller.get('8d4d987dee688e400a0cdc899386f243250d3656d802231755ab4d28178c9816') + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException) + expect(err.response).toStrictEqual({ + statusCode: 404, + message: 'Unable to find masternode', + error: 'Not Found' + }) + } + }) +}) diff --git a/src/module.api/masternode.controller.ts b/src/module.api/masternode.controller.ts new file mode 100644 index 000000000..a2075d3db --- /dev/null +++ b/src/module.api/masternode.controller.ts @@ -0,0 +1,78 @@ +import { NotFoundException, Controller, Get, Query, Param, BadRequestException } from '@nestjs/common' +import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' +import { ApiPagedResponse } from '@src/module.api/_core/api.paged.response' +import { PaginationQuery } from '@src/module.api/_core/api.query' +import { MasternodeData } from '@whale-api-client/api/masternode' +import { MasternodePagination, MasternodeInfo } from '@defichain/jellyfish-api-core/dist/category/masternode' + +@Controller('/v0/:network/masternodes') +export class MasternodesController { + constructor ( + protected readonly client: JsonRpcClient + ) { + } + + /** + * Paginate masternode list. + * + * @param {PaginationQuery} query + * @return {Promise>} + */ + @Get('') + async list ( + @Query() query: PaginationQuery + ): Promise> { + const options: MasternodePagination = { + including_start: query.next === undefined, + limit: query.size, + start: query.next + } + + const data = await this.client.masternode.listMasternodes(options, true) + const masternodes: MasternodeData[] = Object.entries(data) + .map(([id, value]): MasternodeData => mapMasternodeData(id, value)) + .sort((a, b) => a.id.localeCompare(b.id)) + return ApiPagedResponse.of(masternodes, query.size, item => item.id) + } + + /** + * Queries a masternode with given id + * + * @param {string} id + * @return {Promise} + */ + @Get('/:id') + async get (@Param('id') id: string): Promise { + try { + const data = await this.client.masternode.getMasternode(id) + return mapMasternodeData(id, data[Object.keys(data)[0]]) + } catch (err) { + if (err?.payload?.message === 'Masternode not found') { + throw new NotFoundException('Unable to find masternode') + } else { + throw new BadRequestException(err) + } + } + } +} + +function mapMasternodeData (id: string, info: MasternodeInfo): MasternodeData { + return { + id, + state: info.state, + mintedBlocks: info.mintedBlocks, + owner: { + address: info.ownerAuthAddress + }, + operator: { + address: info.operatorAuthAddress + }, + creation: { + height: info.creationHeight + }, + resign: { + tx: info.resignTx, + height: info.resignHeight + } + } +}