From d8260a95bbb17734dc91aee5e3041faceb2fa033 Mon Sep 17 00:00:00 2001 From: Keng Ye <40191153+kyleleow@users.noreply.github.com> Date: Fri, 20 Jan 2023 12:20:34 +0800 Subject: [PATCH] feat(whale-api): add GET api for on-chain governance (#1970) #### What this PR does / why we need it: - Add `/proposals`, `/proposals/:id` and `/proposals/:id/votes` endpoints on whale - Add `.listGovProposals()`, `.getGovProposal()`, and `listGovProposalVotes()` to `WhaleApiClient` - To allow straightforward paginated listing implementation for downstream, e.g. scan and wallet #### Which issue(s) does this PR fixes?: Fixes # #### Additional comments?: - Intermittent codecov issue when running CI, seems to be related to https://github.com/codecov/codecov-action/issues/598 Co-authored-by: Harsh Co-authored-by: Harsh R <53080940+fullstackninja864@users.noreply.github.com> --- apps/whale-api/src/module.api/_module.ts | 8 +- .../module.api/governance.controller.e2e.ts | 402 ++++++++++++++++++ .../src/module.api/governance.controller.ts | 67 +++ .../src/module.api/governance.service.ts | 160 +++++++ .../module.api/pipes/api.validation.pipe.ts | 6 +- .../src/category/governance.ts | 9 +- .../__tests__/api/governance.test.ts | 372 ++++++++++++++++ .../whale-api-client/src/api/governance.ts | 136 ++++++ packages/whale-api-client/src/index.ts | 1 + .../whale-api-client/src/whale.api.client.ts | 15 +- 10 files changed, 1171 insertions(+), 5 deletions(-) create mode 100644 apps/whale-api/src/module.api/governance.controller.e2e.ts create mode 100644 apps/whale-api/src/module.api/governance.controller.ts create mode 100644 apps/whale-api/src/module.api/governance.service.ts create mode 100644 packages/whale-api-client/__tests__/api/governance.test.ts create mode 100644 packages/whale-api-client/src/api/governance.ts diff --git a/apps/whale-api/src/module.api/_module.ts b/apps/whale-api/src/module.api/_module.ts index 1be97fe996..3c3589292b 100644 --- a/apps/whale-api/src/module.api/_module.ts +++ b/apps/whale-api/src/module.api/_module.ts @@ -36,6 +36,8 @@ import { LegacySubgraphService } from './legacy.subgraph.service' import { PoolPairFeesService } from './poolpair.fees.service' import { ConsortiumController } from './consortium.controller' import { ConsortiumService } from './consortium.service' +import { GovernanceController } from './governance.controller' +import { GovernanceService } from './governance.service' /** * Exposed ApiModule for public interfacing @@ -59,7 +61,8 @@ import { ConsortiumService } from './consortium.service' RawtxController, LoanController, LegacyController, - ConsortiumController + ConsortiumController, + GovernanceController ], providers: [ { @@ -104,7 +107,8 @@ import { ConsortiumService } from './consortium.service' inject: [ConfigService] }, LegacySubgraphService, - ConsortiumService + ConsortiumService, + GovernanceService ], exports: [ DeFiDCache diff --git a/apps/whale-api/src/module.api/governance.controller.e2e.ts b/apps/whale-api/src/module.api/governance.controller.e2e.ts new file mode 100644 index 0000000000..93c81031b8 --- /dev/null +++ b/apps/whale-api/src/module.api/governance.controller.e2e.ts @@ -0,0 +1,402 @@ +import { ListProposalsStatus, ListProposalsType, MasternodeType, VoteDecision } from '@defichain/jellyfish-api-core/dist/category/governance' +import { RegTestFoundationKeys } from '@defichain/jellyfish-network' +import { Testing } from '@defichain/jellyfish-testing' +import { MasterNodeRegTestContainer, StartOptions } from '@defichain/testcontainers/dist/index' +import { + GovernanceProposalStatus, + GovernanceProposalType, + ProposalVoteResultType +} from '@defichain/whale-api-client/dist/api/governance' +import { NestFastifyApplication } from '@nestjs/platform-fastify' +import BigNumber from 'bignumber.js' +import { createTestingApp, stopTestingApp } from '../e2e.module' +import { GovernanceController } from './governance.controller' + +class MultiOperatorGovernanceMasterNodeRegTestContainer extends MasterNodeRegTestContainer { + protected getCmd (opts: StartOptions): string[] { + return [ + ...super.getCmd(opts), + `-masternode_operator=${RegTestFoundationKeys[1].operator.address}`, + `-masternode_operator=${RegTestFoundationKeys[2].operator.address}` + ] + } +} +const container = new MultiOperatorGovernanceMasterNodeRegTestContainer() +let app: NestFastifyApplication +let controller: GovernanceController +const testing = Testing.create(container) +let cfpProposalId: string +let vocProposalId: string +let payoutAddress: string + +describe('governance - listProposals and getProposal', () => { + beforeAll(async () => { + await testing.container.start() + await testing.container.waitForWalletCoinbaseMaturity() + await testing.container.waitForWalletBalanceGTE(100) + await testing.container.call('setgov', [ + { ATTRIBUTES: { 'v0/params/feature/gov': 'true' } } + ]) + await testing.container.generate(1) + app = await createTestingApp(container) + controller = app.get(GovernanceController) + + // Create 1 CFP + 1 VOC + payoutAddress = await testing.generateAddress() + cfpProposalId = await testing.rpc.governance.createGovCfp({ + title: 'CFP proposal', + context: 'github', + amount: new BigNumber(1.23), + payoutAddress: payoutAddress, + cycles: 2 + }) + await testing.container.generate(1) + + vocProposalId = await testing.rpc.governance.createGovVoc({ + title: 'VOC proposal', + context: 'github' + }) + await testing.container.generate(1) + }) + + afterAll(async () => { + await stopTestingApp(container, app) + }) + + // Listing related tests + it('should listProposals', async () => { + const result = await controller.listProposals() + const cfpResult = result.data.find(proposal => proposal.type === GovernanceProposalType.COMMUNITY_FUND_PROPOSAL) + const vocResult = result.data.find(proposal => proposal.type === GovernanceProposalType.VOTE_OF_CONFIDENCE) + expect(result.data.length).toStrictEqual(2) + expect(cfpResult).toStrictEqual({ + proposalId: cfpProposalId, + creationHeight: expect.any(Number), + title: 'CFP proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.COMMUNITY_FUND_PROPOSAL, + amount: new BigNumber(1.23).toFixed(8), + payoutAddress: payoutAddress, + currentCycle: 1, + totalCycles: 2, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number) + }) + expect(vocResult).toStrictEqual({ + proposalId: vocProposalId, + creationHeight: expect.any(Number), + title: 'VOC proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.VOTE_OF_CONFIDENCE, + amount: undefined, + currentCycle: 1, + totalCycles: 1, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number) + }) + }) + + it('should listProposals with size', async () => { + const result = await controller.listProposals(undefined, undefined, undefined, undefined, { size: 1 }) + expect(result.data.length).toStrictEqual(1) + }) + + it('should listProposals with status', async () => { + const result = await controller.listProposals(ListProposalsStatus.VOTING) + expect(result.data.length).toStrictEqual(2) + }) + + it('should listProposals with type', async () => { + const result = await controller.listProposals(undefined, ListProposalsType.CFP) + expect(result.data.length).toStrictEqual(1) + }) + + it('should listProposals with cycle', async () => { + const result = await controller.listProposals(undefined, undefined, 0) + expect(result.data.length).toStrictEqual(2) + }) + + it('should listProposals with status and type', async () => { + const result = await controller.listProposals(ListProposalsStatus.VOTING, ListProposalsType.CFP) + expect(result.data.length).toStrictEqual(1) + }) + + it('should listProposals with status, type and cycle', async () => { + const result = await controller.listProposals(ListProposalsStatus.VOTING, ListProposalsType.CFP, 0) + expect(result.data.length).toStrictEqual(1) + }) + + it('should listProposals with pagination', async () => { + const resultPage1 = await controller.listProposals(undefined, undefined, undefined, undefined, { + size: 1 + }) + expect(resultPage1.data.length).toStrictEqual(1) + const resultPage2 = await controller.listProposals(undefined, undefined, undefined, undefined, { + next: resultPage1.page?.next, + size: 1 + }) + expect(resultPage2.data.length).toStrictEqual(1) + }) + + it('should listProposals with all record when limit is 0', async () => { + const result = await controller.listProposals(undefined, undefined, undefined, undefined, { + size: 0 + }) + expect(result.data.length).toStrictEqual(2) + }) + + it('should listProposals with all record when all flag is true', async () => { + const result = await controller.listProposals(undefined, undefined, undefined, true) + expect(result.data.length).toStrictEqual(2) + }) + + it('should listProposals with status and pagination', async () => { + const resultPage1 = await controller.listProposals(ListProposalsStatus.VOTING, undefined, undefined, undefined, { + size: 1 + }) + expect(resultPage1.data.length).toStrictEqual(1) + const resultPage2 = await controller.listProposals(ListProposalsStatus.VOTING, undefined, undefined, undefined, { + next: resultPage1.page?.next, + size: 1 + }) + expect(resultPage2.data.length).toStrictEqual(1) + }) + + // TODO: remove skip when blockchain fixes issue where start is ignored when non-all status is not passed + it.skip('should listProposals with type and pagination', async () => { + const resultPage1 = await controller.listProposals(undefined, ListProposalsType.CFP, undefined, undefined, { + size: 1 + }) + expect(resultPage1.data.length).toStrictEqual(1) + const resultPage2 = await controller.listProposals(undefined, ListProposalsType.CFP, undefined, undefined, { + next: resultPage1.page?.next, + size: 1 + }) + expect(resultPage2.data.length).toStrictEqual(0) + }) + + it('should listProposals with status, type and pagination', async () => { + const resultPage1 = await controller.listProposals(ListProposalsStatus.VOTING, ListProposalsType.CFP, undefined, undefined, { + size: 1 + }) + expect(resultPage1.data.length).toStrictEqual(1) + const resultPage2 = await controller.listProposals(ListProposalsStatus.VOTING, ListProposalsType.CFP, undefined, undefined, { + next: resultPage1.page?.next, + size: 1 + }) + expect(resultPage2.data.length).toStrictEqual(0) + }) + + // Get single related tests + it('should getProposal for CFP', async () => { + const result = await controller.getProposal(cfpProposalId) + expect(result).toStrictEqual({ + proposalId: cfpProposalId, + creationHeight: expect.any(Number), + title: 'CFP proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.COMMUNITY_FUND_PROPOSAL, + amount: new BigNumber(1.23).toFixed(8), + payoutAddress: payoutAddress, + currentCycle: 1, + totalCycles: 2, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number) + }) + }) + + it('should getProposal for VOC', async () => { + const result = await controller.getProposal(vocProposalId) + expect(result).toStrictEqual({ + proposalId: vocProposalId, + creationHeight: 104, + title: 'VOC proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.VOTE_OF_CONFIDENCE, + currentCycle: 1, + totalCycles: 1, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number), + amount: undefined + }) + }) +}) + +describe('governance - listProposalVotes', () => { + beforeAll(async () => { + await testing.container.start() + await testing.container.waitForWalletCoinbaseMaturity() + await testing.container.waitForWalletBalanceGTE(100) + await testing.container.call('setgov', [ + { ATTRIBUTES: { 'v0/params/feature/gov': 'true' } } + ]) + await testing.container.generate(1) + app = await createTestingApp(container) + controller = app.get(GovernanceController) + + /** + * Import the private keys of the masternode_operator in order to be able to mint blocks and vote on proposals. + * This setup uses the default masternode + two additional masternodes for a total of 3 masternodes. + */ + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[1].owner.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[1].operator.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[2].owner.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[2].operator.privKey) + + // Create 1 CFP + 1 VOC + payoutAddress = await testing.generateAddress() + cfpProposalId = await testing.rpc.governance.createGovCfp({ + title: 'CFP proposal', + context: 'github', + amount: new BigNumber(1.23), + payoutAddress: payoutAddress, + cycles: 2 + }) + await testing.container.generate(1) + + vocProposalId = await testing.rpc.governance.createGovVoc({ + title: 'VOC proposal', + context: 'github' + }) + await testing.container.generate(1) + + // Vote on CFP + await testing.rpc.governance.voteGov({ + proposalId: cfpProposalId, + masternodeId: await getVotableMasternodeId(), + decision: VoteDecision.YES + }) + await testing.container.generate(1) + + // Expires cycle 1 + const creationHeight = await testing.rpc.governance.getGovProposal(cfpProposalId).then(proposal => proposal.creationHeight) + const votingPeriod = 70 + const cycle1 = creationHeight + (votingPeriod - creationHeight % votingPeriod) + votingPeriod + await testing.container.generate(cycle1 - await testing.container.getBlockCount()) + + // Vote on cycle 2 + const masternodes = await testing.rpc.masternode.listMasternodes() + const votes = [VoteDecision.YES, VoteDecision.NO, VoteDecision.NEUTRAL] + let index = 0 + for (const [id, data] of Object.entries(masternodes)) { + if (data.operatorIsMine) { + await testing.container.generate(1, data.operatorAuthAddress) // Generate a block to operatorAuthAddress to be allowed to vote on proposal + await testing.rpc.governance.voteGov({ + proposalId: cfpProposalId, + masternodeId: id, + decision: votes[index] + }) + index++ // all masternodes vote in second cycle + } + } + await testing.container.generate(1) + }) + + afterAll(async () => { + await stopTestingApp(container, app) + }) + + it('should listProposalVotes', async () => { + const result = await controller.listProposalVotes(cfpProposalId) + const yesVote = result.data.find(vote => vote.vote === ProposalVoteResultType.YES) + const noVote = result.data.find(vote => vote.vote === ProposalVoteResultType.NO) + const neutralVote = result.data.find(vote => vote.vote === ProposalVoteResultType.NEUTRAL) + expect(result.data.length).toStrictEqual(3) + expect(yesVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.YES + }) + expect(noVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.NO + }) + expect(neutralVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.NEUTRAL + }) + }) + + it('should listProposalVotes with cycle', async () => { + const result = await controller.listProposalVotes(cfpProposalId, undefined, 2) + expect(result.data.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all records when limit is 0', async () => { + const result = await controller.listProposalVotes(cfpProposalId, undefined, undefined, undefined, { size: 0 }) + expect(result.data.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all records when all flag is true', async () => { + const result = await controller.listProposalVotes(cfpProposalId, undefined, undefined, true) + expect(result.data.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes', async () => { + const result = await controller.listProposalVotes(cfpProposalId, MasternodeType.ALL) + expect(result.data.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes and cycle', async () => { + const result = await controller.listProposalVotes(cfpProposalId, MasternodeType.ALL, -1) + expect(result.data.length).toStrictEqual(4) + + const result2 = await controller.listProposalVotes(cfpProposalId, MasternodeType.ALL, 0) + expect(result2.data.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes, cycle and pagination', async () => { + const resultPage1 = await controller.listProposalVotes(cfpProposalId, MasternodeType.ALL, 2, undefined, { size: 2 }) + expect(resultPage1.data.length).toStrictEqual(2) + const resultPage2 = await controller.listProposalVotes(cfpProposalId, MasternodeType.ALL, 2, undefined, { next: resultPage1.page?.next, size: 2 }) + expect(resultPage2.data.length).toStrictEqual(1) + }) +}) + +/** + * Return masternode that mined at least one block to vote on proposal + */ +async function getVotableMasternodeId (): Promise { + const masternodes = await testing.rpc.masternode.listMasternodes() + let masternodeId = '' + for (const id in masternodes) { + const masternode = masternodes[id] + if (masternode.mintedBlocks > 0) { + masternodeId = id + break + } + } + if (masternodeId === '') { + throw new Error('No masternode is available to vote') + } + return masternodeId +} diff --git a/apps/whale-api/src/module.api/governance.controller.ts b/apps/whale-api/src/module.api/governance.controller.ts new file mode 100644 index 0000000000..c334143be0 --- /dev/null +++ b/apps/whale-api/src/module.api/governance.controller.ts @@ -0,0 +1,67 @@ +import { ListProposalsStatus, ListProposalsType, MasternodeType } from '@defichain/jellyfish-api-core/dist/category/governance' +import { + GovernanceProposal, + ProposalVotesResult +} from '@defichain/whale-api-client/dist/api/governance' +import { Controller, DefaultValuePipe, Get, Param, ParseEnumPipe, ParseIntPipe, Query } from '@nestjs/common' +import { GovernanceService } from './governance.service' +import { EnumCustomException } from './pipes/api.validation.pipe' +import { ApiPagedResponse } from './_core/api.paged.response' +import { PaginationQuery } from './_core/api.query' + +@Controller('/governance') +export class GovernanceController { + constructor (private readonly governanceService: GovernanceService) {} + + /** + * Return paginated governance proposals. + * + * @param {ListProposalsStatus} [status=ListProposalsStatus.ALL] type of proposals + * @param {ListProposalsType} [type=ListProposalsType.ALL] status of proposals + * @param {number} [cycle=0] cycle: 0 (show all), cycle: N (show cycle N), cycle: -1 (show previous cycle) + * @param {boolean} [all=false] flag to return all records + * @param {PaginationQuery} query pagination query + * @returns {Promise>} + */ + @Get('/proposals') + async listProposals ( + @Query('status', new ParseEnumPipe(ListProposalsStatus, { exceptionFactory: () => EnumCustomException('status', ListProposalsStatus) }), new DefaultValuePipe(ListProposalsStatus.ALL)) status?: ListProposalsStatus, + @Query('type', new ParseEnumPipe(ListProposalsType, { exceptionFactory: () => EnumCustomException('type', ListProposalsType) }), new DefaultValuePipe(ListProposalsType.ALL)) type?: ListProposalsType, + @Query('cycle', new DefaultValuePipe(0), ParseIntPipe) cycle?: number, + @Query('all', new DefaultValuePipe(false)) all?: boolean, + @Query() query?: PaginationQuery + ): Promise> { + return await this.governanceService.listGovernanceProposal(query, status, type, cycle, all) + } + + /** + * Get information about a proposal with given proposal id. + * + * @param {string} id proposal ID + * @returns {Promise} + */ + @Get('/proposals/:id') + async getProposal (@Param('id') proposalId: string): Promise { + return await this.governanceService.getGovernanceProposal(proposalId) + } + + /** + * Returns information about proposal votes. + * + * @param {string} id proposalId + * @param {MasternodeType | string} [masternode=MasternodeType.ALL] masternode id or reserved words 'mine' to list votes for all owned accounts or 'all' to list all votes + * @param {number} [cycle=0] cycle: 0 (show current), cycle: N (show cycle N), cycle: -1 (show all) + * @param {PaginationQuery} query + * @return {Promise} Proposal vote information + */ + @Get('/proposals/:id/votes') + async listProposalVotes ( + @Param('id') id: string, + @Query('masternode', new DefaultValuePipe(MasternodeType.MINE)) masternode?: MasternodeType | string, + @Query('cycle', new DefaultValuePipe(0), ParseIntPipe) cycle?: number, + @Query('all', new DefaultValuePipe(false)) all?: boolean, + @Query() query?: PaginationQuery + ): Promise> { + return await this.governanceService.getGovernanceProposalVotes(query, id, masternode, cycle, all) + } +} diff --git a/apps/whale-api/src/module.api/governance.service.ts b/apps/whale-api/src/module.api/governance.service.ts new file mode 100644 index 0000000000..645cfab198 --- /dev/null +++ b/apps/whale-api/src/module.api/governance.service.ts @@ -0,0 +1,160 @@ +import { + ListProposalsStatus, + ListProposalsType, + MasternodeType, + ProposalInfo, + ProposalStatus, + ProposalType, + VoteResult +} from '@defichain/jellyfish-api-core/dist/category/governance' +import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' +import { RpcApiError } from '@defichain/jellyfish-api-core' +import { BadRequestException, Injectable } from '@nestjs/common' +import { ApiPagedResponse } from './_core/api.paged.response' +import { PaginationQuery } from './_core/api.query' +import { NotFoundApiException } from './_core/api.error' +import { + GovernanceProposal, + GovernanceProposalStatus, + GovernanceProposalType, + ProposalVoteResultType, + ProposalVotesResult +} from '@defichain/whale-api-client/dist/api/governance' + +@Injectable() +export class GovernanceService { + constructor (private readonly client: JsonRpcClient) {} + + async listGovernanceProposal ( + query: PaginationQuery = { + size: 30 + }, + status?: ListProposalsStatus, + type?: ListProposalsType, + cycle?: number, + all: boolean = false + ): Promise> { + const next = query.next !== undefined ? String(query.next) : undefined + const size = all ? 0 : Math.min(query.size, 30) // blockchain by design to return all records when limit = 0 + const list = await this.client.governance.listGovProposals({ + type, + status, + cycle, + pagination: { + start: next, + limit: size + } + }) + + const proposals = list.map((proposal: ProposalInfo) => { + return this.mapGovernanceProposal(proposal) + }) + + return ApiPagedResponse.of(proposals, size, (proposal) => { + return proposal.proposalId + }) + } + + async getGovernanceProposal (id: string): Promise { + try { + const proposal = await this.client.governance.getGovProposal(id) + return this.mapGovernanceProposal(proposal) + } catch (err) { + if ( + err instanceof RpcApiError && + (err?.payload?.message === `Proposal <${id}> not found` || + err?.payload?.message.includes('proposalId must be of length 64')) + ) { + throw new NotFoundApiException('Unable to find proposal') + } else { + throw new BadRequestException(err) + } + } + } + + async getGovernanceProposalVotes ( + query: PaginationQuery = { + size: 30 + }, + id: string, + masternode: MasternodeType | string = MasternodeType.MINE, + cycle: number = 0, + all?: boolean + ): Promise> { + try { + const next = query.next !== undefined ? Number(query.next) : undefined + const size = all === true ? 0 : Math.min(query.size, 30) // blockchain by design to return all records when limit = 0 + const list = await this.client.governance.listGovProposalVotes({ + proposalId: id, + masternode, + cycle, + pagination: { + start: next, + limit: size + } + }) + const votes = list.map((v) => ({ + ...v, + vote: mapGovernanceProposalVoteResult(v.vote) + })) + + return ApiPagedResponse.of(votes, size, () => { + return (votes.length - 1).toString() + }) + } catch (err) { + if ( + err instanceof RpcApiError && + (err?.payload?.message === `Proposal <${id}> not found` || + err?.payload?.message.includes('proposalId must be of length 64')) + ) { + throw new NotFoundApiException('Unable to find proposal') + } else { + throw new BadRequestException(err) + } + } + } + + private mapGovernanceProposal (data: ProposalInfo): GovernanceProposal { + return { + ...data, + type: mapGovernanceProposalType(data.type), + status: mapGovernanceProposalStatus(data.status), + amount: data.amount?.toFixed(8) + } + } +} + +function mapGovernanceProposalType (type: ProposalType): GovernanceProposalType { + switch (type) { + case ProposalType.COMMUNITY_FUND_PROPOSAL: + return GovernanceProposalType.COMMUNITY_FUND_PROPOSAL + case ProposalType.VOTE_OF_CONFIDENCE: + return GovernanceProposalType.VOTE_OF_CONFIDENCE + } +} + +function mapGovernanceProposalStatus ( + type: ProposalStatus +): GovernanceProposalStatus { + switch (type) { + case ProposalStatus.COMPLETED: + return GovernanceProposalStatus.COMPLETED + case ProposalStatus.REJECTED: + return GovernanceProposalStatus.REJECTED + case ProposalStatus.VOTING: + return GovernanceProposalStatus.VOTING + } +} + +function mapGovernanceProposalVoteResult (type: VoteResult): ProposalVoteResultType { + switch (type) { + case VoteResult.YES: + return ProposalVoteResultType.YES + case VoteResult.NO: + return ProposalVoteResultType.NO + case VoteResult.NEUTRAL: + return ProposalVoteResultType.NEUTRAL + default: + return ProposalVoteResultType.UNKNOWN + } +} diff --git a/apps/whale-api/src/module.api/pipes/api.validation.pipe.ts b/apps/whale-api/src/module.api/pipes/api.validation.pipe.ts index 3ec8694fc4..ecf6b4f6c4 100644 --- a/apps/whale-api/src/module.api/pipes/api.validation.pipe.ts +++ b/apps/whale-api/src/module.api/pipes/api.validation.pipe.ts @@ -1,4 +1,4 @@ -import { ArgumentMetadata, HttpStatus, ParseIntPipe, ValidationError, ValidationPipe } from '@nestjs/common' +import { ArgumentMetadata, BadRequestException, HttpException, HttpStatus, ParseIntPipe, ValidationError, ValidationPipe } from '@nestjs/common' import { ApiErrorType, ApiException } from '../_core/api.error' export class ApiValidationPipe extends ValidationPipe { @@ -65,3 +65,7 @@ export class StringIsIntegerPipe extends ValidationPipe { return value } } + +export function EnumCustomException (parameter: string, enumType: any): HttpException { + return new BadRequestException(`Invalid query parameter value for ${parameter}. See the acceptable values: ${Object.keys(enumType).map(key => enumType[key]).join(', ')}`) +} diff --git a/packages/jellyfish-api-core/src/category/governance.ts b/packages/jellyfish-api-core/src/category/governance.ts index 091250376c..6677cdf89e 100644 --- a/packages/jellyfish-api-core/src/category/governance.ts +++ b/packages/jellyfish-api-core/src/category/governance.ts @@ -31,6 +31,13 @@ export enum VoteDecision { NEUTRAL = 'neutral' } +export enum VoteResult { + YES = 'YES', + NO = 'NO', + NEUTRAL = 'NEUTRAL', + UNKNOWN = 'Unknown' +} + export enum MasternodeType { MINE = 'mine', ALL = 'all' @@ -208,7 +215,7 @@ export interface ListVotesResult { proposalId: string masternodeId: string cycle: number - vote: string + vote: VoteResult } export interface ListProposalsOptions { diff --git a/packages/whale-api-client/__tests__/api/governance.test.ts b/packages/whale-api-client/__tests__/api/governance.test.ts new file mode 100644 index 0000000000..7f59a56a47 --- /dev/null +++ b/packages/whale-api-client/__tests__/api/governance.test.ts @@ -0,0 +1,372 @@ +import { ListProposalsStatus, ListProposalsType, MasternodeType, VoteDecision } from '@defichain/jellyfish-api-core/dist/category/governance' +import { RegTestFoundationKeys } from '@defichain/jellyfish-network' +import { Testing } from '@defichain/jellyfish-testing' +import { MasterNodeRegTestContainer, StartOptions } from '@defichain/testcontainers/dist/index' +import { GovernanceProposalStatus, GovernanceProposalType, ProposalVoteResultType } from '@defichain/whale-api-client/dist/api/governance' +import BigNumber from 'bignumber.js' +import { WhaleApiErrorType, WhaleApiException } from '../../src' +import { StubWhaleApiClient } from '../stub.client' +import { StubService } from '../stub.service' + +class MultiOperatorGovernanceMasterNodeRegTestContainer extends MasterNodeRegTestContainer { + protected getCmd (opts: StartOptions): string[] { + return [ + ...super.getCmd(opts), + `-masternode_operator=${RegTestFoundationKeys[1].operator.address}`, + `-masternode_operator=${RegTestFoundationKeys[2].operator.address}` + ] + } +} + +const container = new MultiOperatorGovernanceMasterNodeRegTestContainer() +const service = new StubService(container) +const client = new StubWhaleApiClient(service) +let cfpProposalId: string +let vocProposalId: string +let payoutAddress: string +let testing: Testing + +describe('governance - listProposals and getProposal', () => { + beforeAll(async () => { + await container.start() + await container.waitForWalletCoinbaseMaturity() + await service.start() + testing = Testing.create(container) + await testing.rpc.masternode.setGov({ ATTRIBUTES: { 'v0/params/feature/gov': 'true' } }) + await testing.generate(1) + + // Create 1 CFP + 1 VOC + payoutAddress = await testing.generateAddress() + cfpProposalId = await testing.rpc.governance.createGovCfp({ + title: 'CFP proposal', + context: 'github', + amount: new BigNumber(1.23), + payoutAddress: payoutAddress, + cycles: 2 + }) + await testing.generate(1) + + vocProposalId = await testing.rpc.governance.createGovVoc({ + title: 'VOC proposal', + context: 'github' + }) + await testing.generate(1) + }) + + afterAll(async () => { + try { + await service.stop() + } finally { + await container.stop() + } + }) + + it('should listGovProposals', async () => { + const result = await client.governance.listGovProposals() + const cfpResult = result.find(proposal => proposal.type === GovernanceProposalType.COMMUNITY_FUND_PROPOSAL) + const vocResult = result.find(proposal => proposal.type === GovernanceProposalType.VOTE_OF_CONFIDENCE) + expect(cfpResult).toStrictEqual({ + proposalId: cfpProposalId, + creationHeight: expect.any(Number), + title: 'CFP proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.COMMUNITY_FUND_PROPOSAL, + amount: new BigNumber(1.23).toFixed(8), + payoutAddress: payoutAddress, + currentCycle: 1, + totalCycles: 2, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number) + }) + expect(vocResult).toStrictEqual({ + proposalId: vocProposalId, + creationHeight: expect.any(Number), + title: 'VOC proposal', + context: 'github', + contextHash: '', + status: GovernanceProposalStatus.VOTING, + type: GovernanceProposalType.VOTE_OF_CONFIDENCE, + currentCycle: 1, + totalCycles: 1, + cycleEndHeight: expect.any(Number), + proposalEndHeight: expect.any(Number), + votingPeriod: expect.any(Number), + quorum: expect.any(String), + approvalThreshold: expect.any(String), + fee: expect.any(Number) + }) + }) + + it('should listProposals with size', async () => { + const result = await client.governance.listGovProposals({ size: 1 }) + expect(result.length).toStrictEqual(1) + }) + + it('should listProposals with status', async () => { + const result = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING }) + expect(result.length).toStrictEqual(2) + }) + + it('should listProposals with type', async () => { + const result = await client.governance.listGovProposals({ type: ListProposalsType.CFP }) + expect(result.length).toStrictEqual(1) + }) + + it('should listProposals with cycle', async () => { + const result = await client.governance.listGovProposals({ cycle: 0 }) + expect(result.length).toStrictEqual(2) + }) + + it('should listProposals with status and type', async () => { + const result = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, type: ListProposalsType.CFP }) + expect(result.length).toStrictEqual(1) + }) + + it('should listProposals with status, type and cycle', async () => { + const result = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, type: ListProposalsType.CFP, cycle: 0 }) + expect(result.length).toStrictEqual(1) + }) + + it('should listProposals with pagination', async () => { + const resultPage1 = await client.governance.listGovProposals({ size: 1 }) + expect(resultPage1.length).toStrictEqual(1) + const resultPage2 = await client.governance.listGovProposals({ size: 1, next: resultPage1.nextToken }) + expect(resultPage2.length).toStrictEqual(1) + expect(resultPage1[0].proposalId).not.toStrictEqual(resultPage2[0].proposalId) + }) + + it('should listProposals with all record when all flag is true', async () => { + const result = await client.governance.listGovProposals({ all: true }) + expect(result.length).toStrictEqual(2) + }) + + it('should listProposals with status and pagination', async () => { + const resultPage1 = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, size: 1 }) + expect(resultPage1.length).toStrictEqual(1) + expect(resultPage1[0].status).toStrictEqual(GovernanceProposalStatus.VOTING) + const resultPage2 = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, size: 1, next: resultPage1.nextToken }) + expect(resultPage2.length).toStrictEqual(1) + expect(resultPage2[0].status).toStrictEqual(GovernanceProposalStatus.VOTING) + }) + + // TODO: remove skip when blockchain fixes issue where start is ignored when non-all status is not passed + it.skip('should listProposals with type and pagination', async () => { + const resultPage1 = await client.governance.listGovProposals({ type: ListProposalsType.CFP, size: 1 }) + expect(resultPage1.length).toStrictEqual(1) + expect(resultPage1[0].type).toStrictEqual(GovernanceProposalType.COMMUNITY_FUND_PROPOSAL) + const resultPage2 = await client.governance.listGovProposals({ type: ListProposalsType.CFP, size: 1, next: resultPage1.nextToken }) + expect(resultPage2.length).toStrictEqual(0) + }) + + it('should listProposals with status, type and pagination', async () => { + const resultPage1 = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, type: ListProposalsType.CFP, size: 1 }) + expect(resultPage1.length).toStrictEqual(1) + expect(resultPage1[0].status).toStrictEqual(GovernanceProposalStatus.VOTING) + expect(resultPage1[0].type).toStrictEqual(GovernanceProposalType.COMMUNITY_FUND_PROPOSAL) + const resultPage2 = await client.governance.listGovProposals({ status: ListProposalsStatus.VOTING, type: ListProposalsType.CFP, size: 1, next: resultPage1.nextToken }) + expect(resultPage2.length).toStrictEqual(0) + }) + + it('should throw error when listProposals called with invalid status', async () => { + await expect( + // To skip typescript validation in order to assert invalid query parameter + // @ts-expect-error + client.governance.listGovProposals({ status: '123' }) + ).rejects.toThrowError( + new WhaleApiException({ + code: 400, + type: WhaleApiErrorType.BadRequest, + at: expect.any(Number), + message: 'Invalid query parameter value for status. See the acceptable values: voting, rejected, completed, all', + url: '/v0.0/regtest/governance/proposals?size=30&status=123&type=all&cycle=0&all=false' + }) + ) + }) + + it('should throw error when listProposals called with invalid type', async () => { + await expect( + // To skip typescript validation in order to assert invalid query parameter + // @ts-expect-error + client.governance.listGovProposals({ type: '123' }) + ).rejects.toThrowError( + new WhaleApiException({ + code: 400, + type: WhaleApiErrorType.BadRequest, + at: expect.any(Number), + message: 'Invalid query parameter value for type. See the acceptable values: cfp, voc, all', + url: '/v0.0/regtest/governance/proposals?size=30&status=all&type=123&cycle=0&all=false' + }) + ) + }) +}) + +describe('governance - listProposalVotes', () => { + beforeAll(async () => { + await container.start() + await container.waitForWalletCoinbaseMaturity() + await service.start() + testing = Testing.create(container) + await testing.rpc.masternode.setGov({ ATTRIBUTES: { 'v0/params/feature/gov': 'true' } }) + await testing.generate(1) + + /** + * Import the private keys of the masternode_operator in order to be able to mint blocks and vote on proposals. + * This setup uses the default masternode + two additional masternodes for a total of 3 masternodes. + */ + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[1].owner.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[1].operator.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[2].owner.privKey) + await testing.rpc.wallet.importPrivKey(RegTestFoundationKeys[2].operator.privKey) + + // Create 1 CFP + 1 VOC + payoutAddress = await testing.generateAddress() + cfpProposalId = await testing.rpc.governance.createGovCfp({ + title: 'CFP proposal', + context: 'github', + amount: new BigNumber(1.23), + payoutAddress: payoutAddress, + cycles: 2 + }) + await testing.generate(1) + + vocProposalId = await testing.rpc.governance.createGovVoc({ + title: 'VOC proposal', + context: 'github' + }) + await testing.generate(1) + + // Vote on CFP + await testing.rpc.governance.voteGov({ + proposalId: cfpProposalId, + masternodeId: await getVotableMasternodeId(), + decision: VoteDecision.YES + }) + await testing.generate(1) + + // Expires cycle 1 + const creationHeight = await testing.rpc.governance.getGovProposal(cfpProposalId).then(proposal => proposal.creationHeight) + const votingPeriod = 70 + const cycle1 = creationHeight + (votingPeriod - creationHeight % votingPeriod) + votingPeriod + await testing.generate(cycle1 - await testing.container.getBlockCount()) + + // Vote on cycle 2 + const masternodes = await testing.rpc.masternode.listMasternodes() + const votes = [VoteDecision.YES, VoteDecision.NO, VoteDecision.NEUTRAL] + let index = 0 + for (const [id, data] of Object.entries(masternodes)) { + if (data.operatorIsMine) { + await testing.container.generate(1, data.operatorAuthAddress) // Generate a block to operatorAuthAddress to be allowed to vote on proposal + await testing.rpc.governance.voteGov({ + proposalId: cfpProposalId, + masternodeId: id, + decision: votes[index] + }) + index++ // all masternodes vote in second cycle + } + } + await testing.generate(1) + }) + + afterAll(async () => { + try { + await service.stop() + } finally { + await container.stop() + } + }) + + it('should listProposalVotes', async () => { + const result = await client.governance.listGovProposalVotes({ id: cfpProposalId }) + const yesVote = result.find(vote => vote.vote === ProposalVoteResultType.YES) + const noVote = result.find(vote => vote.vote === ProposalVoteResultType.NO) + const neutralVote = result.find(vote => vote.vote === ProposalVoteResultType.NEUTRAL) + expect(result.length).toStrictEqual(3) + expect(yesVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.YES + }) + expect(noVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.NO + }) + expect(neutralVote).toStrictEqual({ + proposalId: cfpProposalId, + masternodeId: expect.any(String), + cycle: 2, + vote: ProposalVoteResultType.NEUTRAL + }) + }) + + it('should listProposalVotes with cycle', async () => { + const result = await client.governance.listGovProposalVotes({ id: cfpProposalId, cycle: 2 }) + expect(result.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all records when all flag is true', async () => { + const result = await client.governance.listGovProposalVotes({ id: cfpProposalId, all: true }) + expect(result.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes', async () => { + const result = await client.governance.listGovProposalVotes({ id: cfpProposalId, masternode: MasternodeType.ALL }) + expect(result.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes and cycle', async () => { + const result = await client.governance.listGovProposalVotes({ id: cfpProposalId, masternode: MasternodeType.ALL, cycle: -1 }) + expect(result.length).toStrictEqual(4) + + const result2 = await client.governance.listGovProposalVotes({ id: cfpProposalId, masternode: MasternodeType.ALL, cycle: 0 }) + expect(result2.length).toStrictEqual(3) + }) + + it('should listProposalVotes with all masternodes, cycle and pagination', async () => { + const resultPage1 = await client.governance.listGovProposalVotes({ id: cfpProposalId, masternode: MasternodeType.ALL, cycle: 2, size: 2 }) + expect(resultPage1.length).toStrictEqual(2) + const resultPage2 = await client.governance.listGovProposalVotes({ id: cfpProposalId, masternode: MasternodeType.ALL, cycle: 2, size: 2, next: resultPage1.nextToken }) + expect(resultPage2.length).toStrictEqual(1) + }) + + it('should throw error when listProposalVotes using non-existent proposal ID', async () => { + await expect( + client.governance.listGovProposalVotes({ id: '123' }) + ).rejects.toThrowError( + new WhaleApiException({ + code: 404, + type: WhaleApiErrorType.NotFound, + at: expect.any(Number), + message: 'Unable to find proposal', + url: '/v0.0/regtest/governance/proposals/123/votes?size=30&masternode=mine&cycle=0&all=false' + }) + ) + }) +}) + +/** + * Return masternode that mined at least one block to vote on proposal + */ +async function getVotableMasternodeId (): Promise { + const masternodes = await testing.rpc.masternode.listMasternodes() + let masternodeId = '' + for (const id in masternodes) { + const masternode = masternodes[id] + if (masternode.mintedBlocks > 0) { + masternodeId = id + break + } + } + if (masternodeId === '') { + throw new Error('No masternode is available to vote') + } + return masternodeId +} diff --git a/packages/whale-api-client/src/api/governance.ts b/packages/whale-api-client/src/api/governance.ts new file mode 100644 index 0000000000..b7519c597d --- /dev/null +++ b/packages/whale-api-client/src/api/governance.ts @@ -0,0 +1,136 @@ +import { ListProposalsStatus, ListProposalsType, MasternodeType } from '@defichain/jellyfish-api-core/dist/category/governance' +import { WhaleApiClient } from '../whale.api.client' +import { ApiPagedResponse } from '../whale.api.response' +export class Governance { + constructor (private readonly client: WhaleApiClient) {} + + /** + * Paginate query on-chain governance proposals + * + * @param {ListProposalsStatus} [status=ListProposalsStatus.ALL] proposal status + * @param {ListProposalsType} [type=ListProposalsType.ALL] proposal type + * @param {number} [cycle=0] cycle: 0 (show all), cycle: N (show cycle N), cycle: -1 (show previous cycle) + * @param {number} [size=30] of proposal to query + * @param {string} next set of proposals + * @param {boolean} all true to return all records, otherwise it will return based on size param + * @returns {Promise>} + */ + async listGovProposals (option?: GovernanceListGovProposalsOptions): Promise> { + return await this.client.requestList( + 'GET', + 'governance/proposals', + option?.size ?? 30, + option?.next, + { + status: option?.status ?? ListProposalsStatus.ALL, + type: option?.type ?? ListProposalsType.ALL, + cycle: option?.cycle ?? 0, + all: option?.all ?? false + } + ) + } + + /** + * Get information about a vault with given vault id. + * + * @param {string} id proposal ID + * @returns {Promise} + */ + async getGovProposal (id: string): Promise { + return await this.client.requestData('GET', `governance/proposals/${id}`) + } + + /** + * Returns votes for a proposal + * + * @param {string} id proposal ID + * @param {MasternodeType | string} [masternode=MasternodeType.ALL] masternode id or reserved words 'mine' to list votes for all owned accounts or 'all' to list all votes + * @param {number} [cycle=0] cycle: 0 (show current), cycle: N (show cycle N), cycle: -1 (show all) + * @param {number} [size=30] of proposal to query + * @param {string} next set of proposals + * @param {boolean} all true to return all records, otherwise it will return based on size param + * @return {Promise} Proposal vote information + */ + async listGovProposalVotes (option?: GovernanceListGovProposalVotesOptions): Promise> { + return await this.client.requestList( + 'GET', + `governance/proposals/${option?.id ?? ''}/votes`, + option?.size ?? 30, + option?.next, + { + masternode: option?.masternode ?? MasternodeType.MINE, + cycle: option?.cycle ?? 0, + all: option?.all ?? false + } + ) + } +} + +export interface GovernanceListGovProposalsOptions { + status?: ListProposalsStatus + type?: ListProposalsType + cycle?: number + size?: number + next?: string + all?: boolean +} + +export interface GovernanceListGovProposalVotesOptions { + id: string + masternode?: MasternodeType + cycle?: number + size?: number + next?: string + all?: boolean +} + +export enum GovernanceProposalType { + COMMUNITY_FUND_PROPOSAL = 'CommunityFundProposal', + VOTE_OF_CONFIDENCE = 'VoteOfConfidence', +} + +export enum GovernanceProposalStatus { + VOTING = 'Voting', + REJECTED = 'Rejected', + COMPLETED = 'Completed', +} + +export interface GovernanceProposal { + proposalId: string + title: string + context: string + contextHash: string + type: GovernanceProposalType + status: GovernanceProposalStatus + amount?: string + currentCycle: number + totalCycles: number + creationHeight: number + cycleEndHeight: number + proposalEndHeight: number + payoutAddress?: string + votingPeriod: number + approvalThreshold: string + quorum: string + votesPossible?: number + votesPresent?: number + votesPresentPct?: string + votesYes?: number + votesYesPct?: string + fee: number + options?: string[] +} + +export interface ProposalVotesResult { + proposalId: string + masternodeId: string + cycle: number + vote: ProposalVoteResultType +} + +export enum ProposalVoteResultType { + YES = 'YES', + NO = 'NO', + NEUTRAL = 'NEUTRAL', + UNKNOWN = 'UNKNOWN' +} diff --git a/packages/whale-api-client/src/index.ts b/packages/whale-api-client/src/index.ts index 6457636bc7..349b30ca1e 100644 --- a/packages/whale-api-client/src/index.ts +++ b/packages/whale-api-client/src/index.ts @@ -14,6 +14,7 @@ export * as rawtx from './api/rawtx' export * as fee from './api/fee' export * as loan from './api/loan' export * as consortium from './api/consortium' +export * as governance from './api/governance' 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 d37bb0b89b..bca3d3ac50 100644 --- a/packages/whale-api-client/src/whale.api.client.ts +++ b/packages/whale-api-client/src/whale.api.client.ts @@ -18,6 +18,7 @@ import { Consortium } from './api/consortium' import { ApiPagedResponse, WhaleApiResponse } from './whale.api.response' import { raiseIfError, WhaleApiException, WhaleClientException, WhaleClientTimeoutException } from './errors' import { NetworkName } from '@defichain/jellyfish-network' +import { Governance } from './api/governance' /** * WhaleApiClient Options @@ -63,6 +64,10 @@ export interface ResponseAsString { body: string } +export interface QueryParameter { + [key: string]: string | number | boolean +} + export class WhaleApiClient { public readonly rpc = new Rpc(this) public readonly address = new Address(this) @@ -78,6 +83,7 @@ export class WhaleApiClient { public readonly fee = new Fee(this) public readonly loan = new Loan(this) public readonly consortium = new Consortium(this) + public readonly governance = new Governance(this) constructor ( protected readonly options: WhaleApiClientOptions @@ -113,13 +119,20 @@ export class WhaleApiClient { * @param {string} path to request * @param {number} [size] of the list * @param {string} [next] token for pagination + * @param {QueryParameter} [queryParam] optional query parameter * @return {ApiPagedResponse} data list in the JSON response body for pagination query * @see {paginate(ApiPagedResponse)} for pagination query chaining */ - async requestList (method: Method, path: string, size: number, next?: string): Promise> { + async requestList (method: Method, path: string, size: number, next?: string, queryParam?: QueryParameter): Promise> { const params = new URLSearchParams() params.set('size', size.toString()) + if (queryParam !== undefined) { + for (const query in queryParam) { + params.set(query, queryParam[query].toString()) + } + } + if (next !== undefined) { params.set('next', next) }