diff --git a/apps/whale-api/src/module.api/consortium.controller.spec.ts b/apps/whale-api/src/module.api/consortium.controller.spec.ts index fdc53abe13..f45a165ec4 100644 --- a/apps/whale-api/src/module.api/consortium.controller.spec.ts +++ b/apps/whale-api/src/module.api/consortium.controller.spec.ts @@ -1,6 +1,6 @@ import { ConsortiumController } from './consortium.controller' import { TestingGroup } from '@defichain/jellyfish-testing' -import { createTestingApp, stopTestingApp } from '../e2e.module' +import { createTestingApp, stopTestingApp, waitForIndexedHeight } from '../e2e.module' import { NestFastifyApplication } from '@nestjs/platform-fastify' import { StartFlags } from '@defichain/testcontainers' import { NotFoundException } from '@nestjs/common' @@ -17,18 +17,6 @@ let app: NestFastifyApplication let controller: ConsortiumController const startFlags: StartFlags[] = [{ name: 'regtest-minttoken-simulate-mainnet', value: 1 }] -beforeEach(async () => { - await tGroup.start({ startFlags }) - await alice.container.waitForWalletCoinbaseMaturity() - - app = await createTestingApp(alice.container) - controller = app.get(ConsortiumController) -}) - -afterEach(async () => { - await stopTestingApp(tGroup, app) -}) - async function setGovAttr (attributes: object): Promise { const hash = await alice.rpc.masternode.setGov({ ATTRIBUTES: attributes }) expect(hash).toBeTruthy() @@ -36,7 +24,7 @@ async function setGovAttr (attributes: object): Promise { await tGroup.waitForSync() } -async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, name: string, backingId: string, ownerAddress: string, mintLimit: string, dailyMintLimit: string }>): Promise { +async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, name: string, backingId: string, ownerAddress: string, mintLimit: string, mintLimitDaily: string }>): Promise { const members: { [key: string]: { [key: string]: string } } = {} memberInfo.forEach(mi => { @@ -44,7 +32,7 @@ async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, n name: mi.name, ownerAddress: mi.ownerAddress, backingId: mi.backingId, - mintLimitDaily: mi.dailyMintLimit, + mintLimitDaily: mi.mintLimitDaily, mintLimit: mi.mintLimit } }) @@ -94,14 +82,14 @@ async function setup (): Promise { name: 'alice', ownerAddress: accountAlice, backingId: 'abc', - dailyMintLimit: '5.00000000', + mintLimitDaily: '5.00000000', mintLimit: '10.00000000' }, { id: '02', name: 'bob', ownerAddress: accountBob, backingId: 'def,hij', - dailyMintLimit: '5.00000000', + mintLimitDaily: '5.00000000', mintLimit: '10.00000000' }]) @@ -110,19 +98,31 @@ async function setup (): Promise { name: 'alice', ownerAddress: accountAlice, backingId: '', - dailyMintLimit: '10.00000000', + mintLimitDaily: '10.00000000', mintLimit: '20.00000000' }, { id: '02', name: 'bob', ownerAddress: accountBob, backingId: ' lmn , opq', - dailyMintLimit: '10.00000000', + mintLimitDaily: '10.00000000', mintLimit: '20.00000000' }]) } describe('getAssetBreakdown', () => { + beforeEach(async () => { + await tGroup.start({ startFlags }) + await alice.container.waitForWalletCoinbaseMaturity() + + app = await createTestingApp(alice.container) + controller = app.get(ConsortiumController) + }) + + afterEach(async () => { + await stopTestingApp(tGroup, app) + }) + it('should return an empty list if theres no consortium members or tokens initialized', async () => { const info = await controller.getAssetBreakdown() expect(info).toStrictEqual([]) @@ -189,6 +189,18 @@ describe('getAssetBreakdown', () => { }) describe('getMemberStats', () => { + beforeEach(async () => { + await tGroup.start({ startFlags }) + await alice.container.waitForWalletCoinbaseMaturity() + + app = await createTestingApp(alice.container) + controller = app.get(ConsortiumController) + }) + + afterEach(async () => { + await stopTestingApp(tGroup, app) + }) + it('should throw an error if given consortium member id does not exists', async () => { await setup() @@ -261,6 +273,319 @@ describe('getMemberStats', () => { { tokenSymbol: symbolBTC, tokenDisplaySymbol: `d${symbolBTC}`, tokenId: '1', member: { minted: '6.00000000', mintedDaily: '2.00000000', mintLimit: '10.00000000', mintDailyLimit: '5.00000000' }, token: { minted: '7.00000000', mintedDaily: '3.00000000', mintLimit: '10.00000000', mintDailyLimit: '5.00000000' } }, { tokenSymbol: symbolETH, tokenDisplaySymbol: `d${symbolETH}`, tokenId: '2', member: { minted: '3.20000000', mintedDaily: '3.20000000', mintLimit: '20.00000000', mintDailyLimit: '10.00000000' }, token: { minted: '4.20000000', mintedDaily: '4.20000000', mintLimit: '20.00000000', mintDailyLimit: '10.00000000' } } ] - }) + } + ) + }) +}) + +describe('getTransactionHistory', () => { + const txIdMatcher = expect.stringMatching(/^[0-9a-f]{64}$/) + const txIds: string[] = [] + + beforeAll(async () => { + await tGroup.start({ startFlags }) + await alice.container.waitForWalletCoinbaseMaturity() + + app = await createTestingApp(alice.container) + controller = app.get(ConsortiumController) + + await setup() + }) + + afterAll(async () => { + await stopTestingApp(tGroup, app) + }) + + async function setup (): Promise { + accountAlice = await alice.generateAddress() + accountBob = await bob.generateAddress() + + await alice.token.create({ + symbol: symbolBTC, + name: symbolBTC, + isDAT: true, + mintable: true, + tradeable: true, + collateralAddress: accountAlice + }) + await alice.generate(1) + + await alice.token.create({ + symbol: symbolETH, + name: symbolETH, + isDAT: true, + mintable: true, + tradeable: true, + collateralAddress: accountAlice + }) + await alice.generate(1) + + idBTC = await alice.token.getTokenId(symbolBTC) + idETH = await alice.token.getTokenId(symbolETH) + + await setGovAttr({ + 'v0/params/feature/consortium': 'true', + [`v0/consortium/${idBTC}/mint_limit`]: '20', + [`v0/consortium/${idBTC}/mint_limit_daily`]: '10', + [`v0/consortium/${idETH}/mint_limit`]: '40', + [`v0/consortium/${idETH}/mint_limit_daily`]: '20' + }) + + await setMemberInfo(idBTC, [{ + id: '01', + name: 'alice', + ownerAddress: accountAlice, + backingId: 'abc', + mintLimitDaily: '10', + mintLimit: '20' + }, { + id: '02', + name: 'bob', + ownerAddress: accountBob, + backingId: 'def,hij', + mintLimitDaily: '10', + mintLimit: '20' + }]) + + await setMemberInfo(idETH, [{ + id: '01', + name: 'alice', + ownerAddress: accountAlice, + backingId: '', + mintLimitDaily: '20.00000000', + mintLimit: '40.00000000' + }, { + id: '02', + name: 'bob', + ownerAddress: accountBob, + backingId: 'lmn,opq', + mintLimitDaily: '20.00000000', + mintLimit: '40.00000000' + }]) + + txIds.push(await alice.rpc.token.mintTokens({ amounts: [`0.5@${symbolBTC}`] })) + txIds.push(await bob.rpc.token.mintTokens({ amounts: [`0.5@${symbolBTC}`] })) + await alice.generate(1) + await bob.generate(1) + await tGroup.waitForSync() + + txIds.push(await alice.rpc.token.mintTokens({ amounts: [`0.1@${symbolBTC}`] })) + txIds.push(await alice.rpc.token.burnTokens(`0.1@${symbolBTC}`, accountAlice)) + txIds.push(await bob.rpc.token.mintTokens({ amounts: [`0.1@${symbolBTC}`] })) + txIds.push(await bob.rpc.token.burnTokens(`0.1@${symbolBTC}`, accountBob)) + await alice.generate(1) + await bob.generate(1) + await tGroup.waitForSync() + + await alice.rpc.token.mintTokens({ amounts: [`1@${symbolBTC}`] }) + await alice.generate(1) + + await alice.rpc.token.mintTokens({ amounts: [`2@${symbolETH}`] }) + await alice.generate(1) + + await alice.rpc.token.burnTokens(`1@${symbolETH}`, accountAlice) + await alice.generate(1) + await tGroup.waitForSync() + + await bob.rpc.token.mintTokens({ amounts: [`4@${symbolBTC}`] }) + await bob.generate(1) + + await bob.rpc.token.burnTokens(`2@${symbolBTC}`, accountBob) + await bob.generate(1) + await tGroup.waitForSync() + + const height = await alice.container.getBlockCount() + await alice.generate(1) + await tGroup.waitForSync() + await waitForIndexedHeight(app, height) + } + + it('should throw an error if the search term is invalid', async () => { + try { + await controller.getTransactionHistory({ searchTerm: 'a', size: 1 }) + } catch (err: any) { + expect(err.message).toStrictEqual('422 - ValidationError (/v0.0/regtest/consortium/transactions?next=1&size=10&searchTerm=a)') + expect(err.properties).toStrictEqual([{ + constraints: [ + 'searchTerm must be longer than or equal to 3 characters' + ], + property: 'searchTerm', + value: 'a' + }]) + } + + try { + await controller.getTransactionHistory({ searchTerm: 'a'.repeat(65), size: 1 }) + } catch (err: any) { + expect(err.message).toStrictEqual(`422 - ValidationError (/v0.0/regtest/consortium/transactions?next=1&size=10&searchTerm=${'a'.repeat(65)})`) + expect(err.properties).toStrictEqual([{ + constraints: [ + 'searchTerm must be shorter than or equal to 64 characters' + ], + property: 'searchTerm', + value: 'a'.repeat(65) + }]) + } + }) + + it('should filter transactions with search term (member name)', async () => { + const info = await controller.getTransactionHistory({ searchTerm: 'alice', size: 3 }) + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], address: accountAlice, block: 111 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should filter transactions with search term (owner address)', async () => { + const info = await controller.getTransactionHistory({ searchTerm: accountAlice, size: 3 }) + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], address: accountAlice, block: 111 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should filter transactions with search term (transaction id)', async () => { + const tx = (await alice.rpc.account.listAccountHistory(accountAlice))[0] + + const info = await controller.getTransactionHistory({ searchTerm: tx.txid, size: 20 }) + + expect(info.transactions.length).toStrictEqual(1) + expect(info.transactions).toStrictEqual([ + { txId: tx.txid, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 } + ]) + expect(info.total).toStrictEqual(1) + }) + + it('should limit transactions', async () => { + const info = await controller.getTransactionHistory({ size: 3 }) + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'bob', tokenAmounts: [{ token: symbolBTC, amount: '-2.00000000' }], address: accountBob, block: 115 }, + { txId: txIdMatcher, type: 'Mint', member: 'bob', tokenAmounts: [{ token: symbolBTC, amount: '4.00000000' }], address: accountBob, block: 114 }, + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 } + ]) + expect(info.total).toStrictEqual(11) + }) + + it('should filter and limit transactions at the same time', async () => { + const info = await controller.getTransactionHistory({ searchTerm: accountAlice, size: 2 }) + + expect(info.transactions.length).toStrictEqual(2) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should return empty list of transactions for not-found search term', async () => { + const info = await controller.getTransactionHistory({ searchTerm: 'not-found-term', size: 20 }) + + expect(info.transactions.length).toStrictEqual(0) + expect(info.total).toStrictEqual(0) + }) + + it('should not return other transactions from consortium members apart from mints or burns', async () => { + const { txid } = await alice.container.fundAddress(accountBob, 11.5) + + const height = await alice.container.getBlockCount() + await alice.generate(1) + await waitForIndexedHeight(app, height) + + const info = await controller.getTransactionHistory({ searchTerm: txid, size: 20 }) + + expect(info.transactions.length).toStrictEqual(0) + expect(info.total).toStrictEqual(0) + }) + + it('should paginate properly', async () => { + const page1 = await controller.getTransactionHistory({ next: '0', size: 2 }) + expect(page1).toStrictEqual({ + transactions: [ + { + type: 'Burn', + member: 'bob', + tokenAmounts: [{ token: symbolBTC, amount: '-2.00000000' }], + txId: txIdMatcher, + address: accountBob, + block: 115 + }, + { + type: 'Mint', + member: 'bob', + tokenAmounts: [{ token: symbolBTC, amount: '4.00000000' }], + txId: txIdMatcher, + address: accountBob, + block: 114 + } + ], + total: 11 + }) + + const page2 = await controller.getTransactionHistory({ next: '1', size: 2 }) + expect(page2).toStrictEqual({ + transactions: [ + { + type: 'Burn', + member: 'alice', + tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 113 + }, + { + type: 'Mint', + member: 'alice', + tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 112 + } + ], + total: 11 + }) + + const page3 = await controller.getTransactionHistory({ next: '2', size: 2 }) + expect(page3.transactions[0]).toStrictEqual({ + type: 'Mint', + member: 'alice', + tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 111 + }) + expect(page3.transactions.length).toStrictEqual(2) + expect(page3.total).toStrictEqual(11) + + const page4 = await controller.getTransactionHistory({ next: '3', size: 2 }) + expect(page4.transactions.length).toStrictEqual(2) + expect(page4.total).toStrictEqual(11) + + const page5 = await controller.getTransactionHistory({ next: '4', size: 2 }) + expect(page5.transactions.length).toStrictEqual(2) + expect(page5.total).toStrictEqual(11) + + const page6 = await controller.getTransactionHistory({ next: '5', size: 2 }) + expect(page6.transactions.length).toStrictEqual(1) + expect(page6.total).toStrictEqual(11) + + const txsCombined = [page3.transactions[1]].concat(page4.transactions, page5.transactions, page6.transactions) + + txsCombined.forEach(({ txId }) => { + const index = txIds.indexOf(txId) + expect(index).toBeGreaterThanOrEqual(0) + txIds.splice(index, 1) + }) + + expect(txIds.length).toStrictEqual(0) }) }) diff --git a/apps/whale-api/src/module.api/consortium.controller.ts b/apps/whale-api/src/module.api/consortium.controller.ts index e9b5a079c3..de289ab137 100644 --- a/apps/whale-api/src/module.api/consortium.controller.ts +++ b/apps/whale-api/src/module.api/consortium.controller.ts @@ -1,7 +1,17 @@ -import { Controller, Get, Param } from '@nestjs/common' +import { Controller, Get, Query, Param } from '@nestjs/common' import { ConsortiumService } from './consortium.service' +import { ConsortiumTransactionResponse, AssetBreakdownInfo, MemberStatsInfo } from '@defichain/whale-api-client/dist/api/consortium' import { SemaphoreCache } from '@defichain-apps/libs/caches' -import { AssetBreakdownInfo, MemberStatsInfo } from '@defichain/whale-api-client/dist/api/consortium' +import { PaginationQuery } from './_core/api.query' +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator' + +class TransactionHistoryPaginationQuery extends PaginationQuery { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(64) + searchTerm?: string +} @Controller('/consortium') export class ConsortiumController { @@ -10,6 +20,26 @@ export class ConsortiumController { protected readonly cache: SemaphoreCache ) {} + /** + * Gets the transaction history of consortium members. + * + * @return {Promise} + */ + @Get('/transactions') + async getTransactionHistory ( + @Query() query: TransactionHistoryPaginationQuery = { size: 50 } + ): Promise { + const { searchTerm = '' } = query + const next = query.next !== undefined ? Number(query.next) : 0 + const size = Math.min(query.size, 50) + + return await this.cache.get(`CONSORTIUM_TRANSACTIONS_${JSON.stringify({ next, size, searchTerm })}`, async () => { + return await this.consortiumService.getTransactionHistory(next, size, searchTerm) + }, { + ttl: 600 // 10 mins + }) as ConsortiumTransactionResponse + } + /** * Gets the asset breakdown information of consortium members. * diff --git a/apps/whale-api/src/module.api/consortium.service.ts b/apps/whale-api/src/module.api/consortium.service.ts index 333b5e9760..1b77d9ea42 100644 --- a/apps/whale-api/src/module.api/consortium.service.ts +++ b/apps/whale-api/src/module.api/consortium.service.ts @@ -1,7 +1,19 @@ import { Injectable, NotFoundException } from '@nestjs/common' import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' +import { SemaphoreCache } from '@defichain-apps/libs/caches' +import { + ConsortiumTransactionResponse, + Transaction, + AssetBreakdownInfo, + MemberDetail, + MemberWithTokenInfo, + MemberStatsInfo, + TokenWithMintStatsInfo, + MintTokenWithStats +} from '@defichain/whale-api-client/dist/api/consortium' +import { AccountHistory, DfTxType } from '@defichain/jellyfish-api-core/dist/category/account' +import { TransactionMapper } from '../module.model/transaction' import { DeFiDCache, TokenInfoWithId } from './cache/defid.cache' -import { AssetBreakdownInfo, MemberDetail, MemberWithTokenInfo, MemberStatsInfo, TokenWithMintStatsInfo, MintTokenWithStats } from '@defichain/whale-api-client/dist/api/consortium' import BigNumber from 'bignumber.js' import { parseDisplaySymbol } from './token.controller' @@ -9,9 +21,119 @@ import { parseDisplaySymbol } from './token.controller' export class ConsortiumService { constructor ( protected readonly rpcClient: JsonRpcClient, - private readonly defidCache: DeFiDCache + private readonly defidCache: DeFiDCache, + protected readonly cache: SemaphoreCache, + protected readonly transactionMapper: TransactionMapper ) {} + private formatTransactionResponse (tx: AccountHistory, members: MemberDetail[]): Transaction { + return { + type: tx.type === 'MintToken' ? 'Mint' : 'Burn', + member: members.find(m => m.ownerAddress === tx.owner)?.name ?? '', + tokenAmounts: tx.amounts.map((a: any) => { + const splits = a.split('@') + return { token: splits[1], amount: splits[0] } + }), + txId: tx.txid, + address: tx.owner, + block: tx.blockHeight + } + } + + private isValidTxIdFormat (value: string): boolean { + return /^[0-9a-f]{64}$/.exec(value) !== null + } + + private async getFilteredUniqueMembers (searchTerm: string): Promise { + const attrs = (await this.rpcClient.masternode.getGov('ATTRIBUTES')).ATTRIBUTES + const membersKeyRegex: RegExp = /^v0\/consortium\/\d+\/members$/ + const searchForMemberDetail = searchTerm !== '' && !this.isValidTxIdFormat(searchTerm) + + return (Object.entries(attrs) as [[string, object]]).reduce((prev: MemberDetail[], [key, value]) => { + if (membersKeyRegex.exec(key) === null) { + return prev + } + + (Object.entries(value) as [[string, MemberDetail]]).forEach(([memberId, memberDetail]) => { + if (searchForMemberDetail) { + if (!(memberDetail.ownerAddress === searchTerm || memberDetail.name.toLowerCase().includes(searchTerm))) { + return prev + } + } + + if (!prev.some(m => m.id === memberId)) { + prev.push({ + id: memberId, + name: memberDetail.name, + ownerAddress: memberDetail.ownerAddress + }) + } + }) + + return prev + }, []) + } + + private async getSingleHistoryTransactionResponse (searchTerm: string, members: MemberDetail[]): Promise { + const foundTx = await this.transactionMapper.get(searchTerm) + if (foundTx === undefined) { + return { + transactions: [], + total: 0 + } + } + + const relevantTxsOnBlock = await this.rpcClient.account.listAccountHistory(members.map(m => m.ownerAddress), { + maxBlockHeight: foundTx.block.height, + depth: 0, + txtypes: [DfTxType.MINT_TOKEN, DfTxType.BURN_TOKEN] + }) + const transaction = relevantTxsOnBlock.find(tx => tx.txid === foundTx.txid) + if (transaction === undefined) { + return { + transactions: [], + total: 0 + } + } + + return { + transactions: [this.formatTransactionResponse(transaction, members)], + total: 1 + } + } + + private async getPaginatedHistoryTransactionsResponse (pageIndex: number, limit: number, members: MemberDetail[]): Promise { + const memberAddresses = members.map(m => m.ownerAddress) + + const transactions: AccountHistory[] = await this.rpcClient.account.listAccountHistory(memberAddresses, { + no_rewards: true, + txtypes: [DfTxType.MINT_TOKEN, DfTxType.BURN_TOKEN], + including_start: true, + start: pageIndex * limit, + limit + }) + + const totalTxCount = await this.rpcClient.account.historyCount(memberAddresses, { txtypes: [DfTxType.BURN_TOKEN, DfTxType.MINT_TOKEN] }) + + return { + transactions: transactions.map(tx => { + return this.formatTransactionResponse(tx, members) + }), + total: totalTxCount + } + } + + async getTransactionHistory (pageIndex: number, limit: number, searchTerm: string): Promise { + const members = await this.getFilteredUniqueMembers(searchTerm) + + const searchForTxId = searchTerm !== '' && this.isValidTxIdFormat(searchTerm) + if (searchForTxId) { + return await this.getSingleHistoryTransactionResponse(searchTerm, members) + } + + return await this.getPaginatedHistoryTransactionsResponse(pageIndex, limit, members) + } + private updateBurnMintAmounts (assetBreakdownInfo: AssetBreakdownInfo[], tokens: TokenInfoWithId[], key: string, value: string): void { const tokenId = key.split('/')[4] const memberId = key.split('/')[5] @@ -36,7 +158,7 @@ export class ConsortiumService { } private pushToAssetBreakdownInfo (assetBreakdownInfo: AssetBreakdownInfo[], memberId: string, memberDetail: MemberDetail, tokenId: string, tokens: TokenInfoWithId[]): void { - const backingAddresses: string[] = memberDetail.backingId.length > 0 ? memberDetail.backingId.split(',').map(a => a.trim()) : [] + const backingAddresses: string[] = memberDetail.backingId !== undefined && memberDetail.backingId.length > 0 ? memberDetail.backingId.split(',').map(a => a.trim()) : [] const member: MemberWithTokenInfo = { id: memberId, diff --git a/packages/whale-api-client/__tests__/api/consortium.test.ts b/packages/whale-api-client/__tests__/api/consortium.test.ts index 2175a335ed..f04049a0ab 100644 --- a/packages/whale-api-client/__tests__/api/consortium.test.ts +++ b/packages/whale-api-client/__tests__/api/consortium.test.ts @@ -3,6 +3,8 @@ import { StartFlags } from '@defichain/testcontainers' import { WhaleApiException } from '../../src' import { StubWhaleApiClient } from '../stub.client' import { StubService } from '../stub.service' +import { NestFastifyApplication } from '@nestjs/platform-fastify' +import { createTestingApp, stopTestingApp, waitForIndexedHeight } from '../../../../apps/whale-api/src/e2e.module' const tGroup = TestingGroup.create(2) const alice = tGroup.get(0) @@ -17,20 +19,6 @@ const startFlags: StartFlags[] = [{ name: 'regtest-minttoken-simulate-mainnet', const service = new StubService(alice.container) const client = new StubWhaleApiClient(service) -beforeEach(async () => { - await tGroup.start({ startFlags }) - await service.start() - await alice.container.waitForWalletCoinbaseMaturity() -}) - -afterEach(async () => { - try { - await service.stop() - } finally { - await tGroup.stop() - } -}) - async function setGovAttr (attributes: object): Promise { const hash = await alice.rpc.masternode.setGov({ ATTRIBUTES: attributes }) expect(hash).toBeTruthy() @@ -38,7 +26,7 @@ async function setGovAttr (attributes: object): Promise { await tGroup.waitForSync() } -async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, name: string, backingId: string, ownerAddress: string, mintLimit: string, dailyMintLimit: string }>): Promise { +async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, name: string, backingId: string, ownerAddress: string, mintLimit: string, mintLimitDaily: string }>): Promise { const members: { [key: string]: { [key: string]: string } } = {} memberInfo.forEach(mi => { @@ -46,7 +34,7 @@ async function setMemberInfo (tokenId: string, memberInfo: Array<{ id: string, n name: mi.name, ownerAddress: mi.ownerAddress, backingId: mi.backingId, - mintLimitDaily: mi.dailyMintLimit, + mintLimitDaily: mi.mintLimitDaily, mintLimit: mi.mintLimit } }) @@ -96,14 +84,14 @@ async function setup (): Promise { name: 'alice', ownerAddress: accountAlice, backingId: 'abc', - dailyMintLimit: '5.00000000', + mintLimitDaily: '5.00000000', mintLimit: '10.00000000' }, { id: '02', name: 'bob', ownerAddress: accountBob, backingId: 'def,hij', - dailyMintLimit: '5.00000000', + mintLimitDaily: '5.00000000', mintLimit: '10.00000000' }]) @@ -112,19 +100,33 @@ async function setup (): Promise { name: 'alice', ownerAddress: accountAlice, backingId: '', - dailyMintLimit: '10.00000000', + mintLimitDaily: '10.00000000', mintLimit: '20.00000000' }, { id: '02', name: 'bob', ownerAddress: accountBob, backingId: ' lmn , opq', - dailyMintLimit: '10.00000000', + mintLimitDaily: '10.00000000', mintLimit: '20.00000000' }]) } describe('getAssetBreakdown', () => { + beforeEach(async () => { + await tGroup.start({ startFlags }) + await service.start() + await alice.container.waitForWalletCoinbaseMaturity() + }) + + afterEach(async () => { + try { + await service.stop() + } finally { + await tGroup.stop() + } + }) + it('should respond an empty list if theres no consortium members or tokens initialized', async () => { const info = await client.consortium.getAssetBreakdown() expect(info).toStrictEqual([]) @@ -190,7 +192,338 @@ describe('getAssetBreakdown', () => { }) }) +describe('getTransactionHistory', () => { + let app: NestFastifyApplication + const txIdMatcher = expect.stringMatching(/^[0-9a-f]{64}$/) + const txIds: string[] = [] + + beforeAll(async () => { + await tGroup.start({ startFlags }) + await service.start() + await alice.container.waitForWalletCoinbaseMaturity() + + app = await createTestingApp(alice.container) + + await setup() + }) + + afterAll(async () => { + try { + await service.stop() + } finally { + await stopTestingApp(tGroup, app) + } + }) + + async function setup (): Promise { + accountAlice = await alice.generateAddress() + accountBob = await bob.generateAddress() + + await alice.token.create({ + symbol: symbolBTC, + name: symbolBTC, + isDAT: true, + mintable: true, + tradeable: true, + collateralAddress: accountAlice + }) + await alice.generate(1) + + await alice.token.create({ + symbol: symbolETH, + name: symbolETH, + isDAT: true, + mintable: true, + tradeable: true, + collateralAddress: accountAlice + }) + await alice.generate(1) + + idBTC = await alice.token.getTokenId(symbolBTC) + idETH = await alice.token.getTokenId(symbolETH) + + await setGovAttr({ + 'v0/params/feature/consortium': 'true', + [`v0/consortium/${idBTC}/mint_limit`]: '20', + [`v0/consortium/${idBTC}/mint_limit_daily`]: '10', + [`v0/consortium/${idETH}/mint_limit`]: '40', + [`v0/consortium/${idETH}/mint_limit_daily`]: '20' + }) + + await setMemberInfo(idBTC, [{ + id: '01', + name: 'alice', + ownerAddress: accountAlice, + backingId: 'abc', + mintLimitDaily: '10', + mintLimit: '20' + }, { + id: '02', + name: 'bob', + ownerAddress: accountBob, + backingId: 'def,hij', + mintLimitDaily: '10', + mintLimit: '20' + }]) + + await setMemberInfo(idETH, [{ + id: '01', + name: 'alice', + ownerAddress: accountAlice, + backingId: '', + mintLimitDaily: '20.00000000', + mintLimit: '40.00000000' + }, { + id: '02', + name: 'bob', + ownerAddress: accountBob, + backingId: 'lmn,opq', + mintLimitDaily: '20.00000000', + mintLimit: '40.00000000' + }]) + + txIds.push(await alice.rpc.token.mintTokens({ amounts: [`0.5@${symbolBTC}`] })) + txIds.push(await bob.rpc.token.mintTokens({ amounts: [`0.5@${symbolBTC}`] })) + await alice.generate(1) + await bob.generate(1) + await tGroup.waitForSync() + + txIds.push(await alice.rpc.token.mintTokens({ amounts: [`0.1@${symbolBTC}`] })) + txIds.push(await alice.rpc.token.burnTokens(`0.1@${symbolBTC}`, accountAlice)) + txIds.push(await bob.rpc.token.mintTokens({ amounts: [`0.1@${symbolBTC}`] })) + txIds.push(await bob.rpc.token.burnTokens(`0.1@${symbolBTC}`, accountBob)) + await alice.generate(1) + await bob.generate(1) + await tGroup.waitForSync() + + await alice.rpc.token.mintTokens({ amounts: [`1@${symbolBTC}`] }) + await alice.generate(1) + + await alice.rpc.token.mintTokens({ amounts: [`2@${symbolETH}`] }) + await alice.generate(1) + + await alice.rpc.token.burnTokens(`1@${symbolETH}`, accountAlice) + await alice.generate(1) + await tGroup.waitForSync() + + await bob.rpc.token.mintTokens({ amounts: [`4@${symbolBTC}`] }) + await bob.generate(1) + + await bob.rpc.token.burnTokens(`2@${symbolBTC}`, accountBob) + await bob.generate(1) + await tGroup.waitForSync() + + const height = await alice.container.getBlockCount() + await alice.generate(1) + await tGroup.waitForSync() + await waitForIndexedHeight(app, height) + } + + it('should throw an error if the search term is invalid', async () => { + try { + await client.consortium.getTransactionHistory(1, 10, 'a') + } catch (err: any) { + expect(err.message).toStrictEqual('422 - ValidationError (/v0.0/regtest/consortium/transactions?next=1&size=10&searchTerm=a)') + expect(err.properties).toStrictEqual([{ + constraints: [ + 'searchTerm must be longer than or equal to 3 characters' + ], + property: 'searchTerm', + value: 'a' + }]) + } + + try { + await client.consortium.getTransactionHistory(1, 10, 'a'.repeat(65)) + } catch (err: any) { + expect(err.message).toStrictEqual(`422 - ValidationError (/v0.0/regtest/consortium/transactions?next=1&size=10&searchTerm=${'a'.repeat(65)})`) + expect(err.properties).toStrictEqual([{ + constraints: [ + 'searchTerm must be shorter than or equal to 64 characters' + ], + property: 'searchTerm', + value: 'a'.repeat(65) + }]) + } + }) + + it('should filter transactions with search term (member name)', async () => { + const info = await client.consortium.getTransactionHistory(0, 3, 'alice') + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], address: accountAlice, block: 111 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should filter transactions with search term (owner address)', async () => { + const info = await client.consortium.getTransactionHistory(0, 3, accountAlice) + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], address: accountAlice, block: 111 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should filter transactions with search term (transaction id)', async () => { + const tx = (await alice.rpc.account.listAccountHistory(accountAlice))[0] + + const info = await client.consortium.getTransactionHistory(0, 10, tx.txid) + + expect(info.transactions.length).toStrictEqual(1) + expect(info.transactions).toStrictEqual([ + { txId: tx.txid, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 } + ]) + expect(info.total).toStrictEqual(1) + }) + + it('should limit transactions', async () => { + const info = await client.consortium.getTransactionHistory(0, 3) + + expect(info.transactions.length).toStrictEqual(3) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'bob', tokenAmounts: [{ token: symbolBTC, amount: '-2.00000000' }], address: accountBob, block: 115 }, + { txId: txIdMatcher, type: 'Mint', member: 'bob', tokenAmounts: [{ token: symbolBTC, amount: '4.00000000' }], address: accountBob, block: 114 }, + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 } + ]) + expect(info.total).toStrictEqual(11) + }) + + it('should filter and limit transactions at the same time', async () => { + const info = await client.consortium.getTransactionHistory(0, 2, accountAlice) + + expect(info.transactions.length).toStrictEqual(2) + expect(info.transactions).toStrictEqual([ + { txId: txIdMatcher, type: 'Burn', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], address: accountAlice, block: 113 }, + { txId: txIdMatcher, type: 'Mint', member: 'alice', tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], address: accountAlice, block: 112 } + ]) + expect(info.total).toStrictEqual(6) + }) + + it('should return empty list of transactions for not-found search term', async () => { + const info = await client.consortium.getTransactionHistory(0, 10, 'not-found-term') + + expect(info.transactions.length).toStrictEqual(0) + expect(info.total).toStrictEqual(0) + }) + + it('should not return other transactions from consortium members apart from mints or burns', async () => { + const { txid } = await alice.container.fundAddress(accountBob, 10) + + const height = await alice.container.getBlockCount() + await alice.generate(1) + await waitForIndexedHeight(app, height) + + const info = await client.consortium.getTransactionHistory(0, 20, txid) + + expect(info.transactions.length).toStrictEqual(0) + expect(info.total).toStrictEqual(0) + }) + + it('should paginate properly', async () => { + const page1 = await client.consortium.getTransactionHistory(0, 2) + expect(page1).toStrictEqual({ + transactions: [ + { + type: 'Burn', + member: 'bob', + tokenAmounts: [{ token: symbolBTC, amount: '-2.00000000' }], + txId: txIdMatcher, + address: accountBob, + block: 115 + }, + { + type: 'Mint', + member: 'bob', + tokenAmounts: [{ token: symbolBTC, amount: '4.00000000' }], + txId: txIdMatcher, + address: accountBob, + block: 114 + } + ], + total: 11 + }) + + const page2 = await client.consortium.getTransactionHistory(1, 2) + expect(page2).toStrictEqual({ + transactions: [ + { + type: 'Burn', + member: 'alice', + tokenAmounts: [{ token: symbolETH, amount: '-1.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 113 + }, + { + type: 'Mint', + member: 'alice', + tokenAmounts: [{ token: symbolETH, amount: '2.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 112 + } + ], + total: 11 + }) + + const page3 = await client.consortium.getTransactionHistory(2, 2) + expect(page3.transactions[0]).toStrictEqual({ + type: 'Mint', + member: 'alice', + tokenAmounts: [{ token: symbolBTC, amount: '1.00000000' }], + txId: txIdMatcher, + address: accountAlice, + block: 111 + }) + expect(page3.transactions.length).toStrictEqual(2) + expect(page3.total).toStrictEqual(11) + + const page4 = await client.consortium.getTransactionHistory(3, 2) + expect(page4.transactions.length).toStrictEqual(2) + expect(page4.total).toStrictEqual(11) + + const page5 = await client.consortium.getTransactionHistory(4, 2) + expect(page5.transactions.length).toStrictEqual(2) + expect(page5.total).toStrictEqual(11) + + const page6 = await client.consortium.getTransactionHistory(5, 2) + expect(page6.transactions.length).toStrictEqual(1) + expect(page6.total).toStrictEqual(11) + + const txsCombined = [page3.transactions[1]].concat(page4.transactions, page5.transactions, page6.transactions) + + txsCombined.forEach(({ txId }) => { + const index = txIds.indexOf(txId) + expect(index).toBeGreaterThanOrEqual(0) + txIds.splice(index, 1) + }) + + expect(txIds.length).toStrictEqual(0) + }) +}) + describe('getMemberStats', () => { + beforeEach(async () => { + await tGroup.start({ startFlags }) + await service.start() + await alice.container.waitForWalletCoinbaseMaturity() + }) + + afterEach(async () => { + try { + await service.stop() + } finally { + await tGroup.stop() + } + }) + it('should throw an error if provided consortium member id is invalid', async () => { try { await setup() diff --git a/packages/whale-api-client/src/api/consortium.ts b/packages/whale-api-client/src/api/consortium.ts index e25e93ba3b..afbb8ef29e 100644 --- a/packages/whale-api-client/src/api/consortium.ts +++ b/packages/whale-api-client/src/api/consortium.ts @@ -25,10 +25,52 @@ export class Consortium { async getMemberStats (memberid: string): Promise { return await this.client.requestData('GET', `consortium/stats/${memberid}`) } + + /** + * Gets the transaction history of consortium members. + * + * @param {number} [pageIndex] The starting index for pagination + * @param {number} [limit] How many transactions to fetch + * @param {string} [searchTerm] Search term, can be a transaction id, member/owner address or name + * @return {Promise} + */ + async getTransactionHistory (next?: number, size?: number, searchTerm?: string): Promise { + const query = [] + + if (next !== undefined) { + query.push(`next=${next}`) + } + + if (size !== undefined) { + query.push(`size=${size}`) + } + + if (searchTerm !== undefined) { + query.push(`searchTerm=${searchTerm}`) + } + + return await this.client.requestData('GET', `consortium/transactions?${query.join('&')}`) + } +} + +export interface ConsortiumTransactionResponse { + transactions: Transaction[] + total: number +} + +export interface Transaction { + type: 'Mint' | 'Burn' + member: string + tokenAmounts: Array<{ token: string, amount: string }> + txId: string + address: string + block: number } export interface MemberDetail { - backingId: string + id: string + backingId?: string + ownerAddress: string name: string }