diff --git a/packages/whale-api-client/__tests__/api/anchors.test.ts b/packages/whale-api-client/__tests__/api/anchors.test.ts new file mode 100644 index 000000000..cddb731cb --- /dev/null +++ b/packages/whale-api-client/__tests__/api/anchors.test.ts @@ -0,0 +1,160 @@ +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { RegTestFoundationKeys } from '@defichain/jellyfish-network' +import { StubService } from '../stub.service' +import { StubWhaleApiClient } from '../stub.client' +import { WhaleApiClient } from '../../src' +import { TestingGroup } from '@defichain/jellyfish-testing' + +let container: MasterNodeRegTestContainer +let service: StubService +let client: WhaleApiClient +let tGroup: TestingGroup + +beforeAll(async () => { + tGroup = TestingGroup.create(3) + container = tGroup.group.get(0) + service = new StubService(container) + client = new StubWhaleApiClient(service) + + await tGroup.group.start() + await service.start() + await setup() +}) + +async function setMockTime (offsetHour: number): Promise { + await tGroup.exec(async (testing: any) => { + await testing.misc.offsetTimeHourly(offsetHour) + }) +} + +async function setup (): Promise { + { + const auths = await tGroup.get(0).container.call('spv_listanchorauths') + expect(auths.length).toStrictEqual(0) + } + + const initOffsetHour = -12 + await setMockTime(initOffsetHour) + + for (let i = 0; i < 15; i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + await container.generate(1) + await tGroup.waitForSync() + } + + await tGroup.get(0).container.waitForAnchorTeams(tGroup.length()) + + for (let i = 0; i < tGroup.length(); i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + const team = await container.call('getanchorteams') + expect(team.auth.length).toStrictEqual(tGroup.length()) + expect(team.confirm.length).toStrictEqual(tGroup.length()) + expect(team.auth.includes(RegTestFoundationKeys[0].operator.address)) + expect(team.auth.includes(RegTestFoundationKeys[1].operator.address)) + expect(team.auth.includes(RegTestFoundationKeys[2].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[0].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[1].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[2].operator.address)) + } + + await tGroup.anchor.generateAnchorAuths(2, initOffsetHour) + + await tGroup.get(0).container.waitForAnchorAuths(tGroup.length()) + + for (let i = 0; i < tGroup.length(); i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + const auths = await container.call('spv_listanchorauths') + expect(auths.length).toStrictEqual(2) + expect(auths[0].signers).toStrictEqual(tGroup.length()) + } + + await tGroup.get(0).container.call('spv_setlastheight', [1]) + const anchor1 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [2]) + const anchor2 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [3]) + const anchor3 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [4]) + const anchor4 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(1).container.call('spv_sendrawtx', [anchor1.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor2.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor3.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor4.txHex]) + await tGroup.get(1).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [6]) +} + +async function createAnchor (): Promise { + const rewardAddress = await tGroup.get(0).rpc.spv.getNewAddress() + return await tGroup.get(0).rpc.spv.createAnchor([{ + txid: '11a276bb25585f6973a4dd68373cffff41dbcaddf12bbc1c2b489d1dc84564ee', + vout: 2, + amount: 15800, + privkey: 'b0528d87cfdb09f72c9d10b7b3cc00727062d93537a3e8abcf1fde821d08b59d' + }], rewardAddress) +} + +afterAll(async () => { + try { + await service.stop() + } finally { + await tGroup.group.stop() + } +}) + +describe('list', () => { + it('should list anchors', async () => { + const result = await client.anchors.list() + expect(result[0]).toStrictEqual({ + id: '4', + btc: { + block: { + height: 4, + hash: '0000000000000001000000000000000100000000000000010000000000000001' + }, + txn: { + hash: expect.any(String) + }, + confirmations: 3 + }, + dfi: { + block: { + height: 30, + hash: expect.any(String) + } + }, + previousAnchor: '0000000000000000000000000000000000000000000000000000000000000000', + rewardAddress: expect.any(String), + signatures: 2, + active: false, + anchorCreationHeight: 90 + }) + }) + + it('should test pagination with maxBtcHeight', async () => { + const firstRequest = await client.anchors.list(1) + expect(firstRequest.length).toStrictEqual(1) + expect(firstRequest.nextToken).toStrictEqual('3') + + const secondRequest = await client.anchors.list(1, firstRequest.nextToken) + expect(secondRequest.length).toStrictEqual(1) + expect(secondRequest.nextToken).toStrictEqual('2') + + const thirdRequest = await client.anchors.list(1, secondRequest.nextToken) + expect(thirdRequest.length).toStrictEqual(1) + expect(thirdRequest.nextToken).toStrictEqual('1') + + const lastRequest = await client.anchors.list(2, thirdRequest.nextToken) + expect(lastRequest.length).toStrictEqual(1) + expect(lastRequest.nextToken).toStrictEqual(undefined) + }) +}) diff --git a/packages/whale-api-client/src/api/anchors.ts b/packages/whale-api-client/src/api/anchors.ts new file mode 100644 index 000000000..965fd0d8e --- /dev/null +++ b/packages/whale-api-client/src/api/anchors.ts @@ -0,0 +1,46 @@ +import { WhaleApiClient } from '../whale.api.client' +import { ApiPagedResponse } from '../whale.api.response' + +/** + * DeFi whale endpoint for Anchors related services. + */ + +export class Anchors { + constructor (private readonly client: WhaleApiClient) {} + + /** + * Paginate query anchors. + * + * @param {number} size of anchors to query + * @param {string} next set of anchors (startBtcHeight) + * @return {Promise>} + */ + async list (size: number = 30, next?: string): Promise> { + return await this.client.requestList('GET', 'anchors', size, next) + } +} + +export interface AnchorData { + id: string /* ------------ ID is height of the btc block */ + btc: { + block: { + height: number + hash: string + } + txn: { + hash: string + } + confirmations: number + } + dfi: { + block: { + height: number + hash: string + } + } + previousAnchor: string + rewardAddress: string + signatures: number + active?: boolean + anchorCreationHeight?: number +} diff --git a/packages/whale-api-client/src/index.ts b/packages/whale-api-client/src/index.ts index 8f12fa3c8..7eeec2af2 100644 --- a/packages/whale-api-client/src/index.ts +++ b/packages/whale-api-client/src/index.ts @@ -13,6 +13,7 @@ export * as stats from './api/stats' export * as rawtx from './api/rawtx' export * as fee from './api/fee' export * as loan from './api/loan' +export * as anchors from './api/anchors' 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 24c5ac8ef..817f3191f 100644 --- a/packages/whale-api-client/src/whale.api.client.ts +++ b/packages/whale-api-client/src/whale.api.client.ts @@ -17,6 +17,7 @@ import { Stats } from './api/stats' import { Rawtx } from './api/rawtx' import { Fee } from './api/fee' import { Loan } from './api/loan' +import { Anchors } from './api/anchors' /** * WhaleApiClient Options @@ -76,6 +77,7 @@ export class WhaleApiClient { public readonly rawtx = new Rawtx(this) public readonly fee = new Fee(this) public readonly loan = new Loan(this) + public readonly anchors = new Anchors(this) constructor ( protected readonly options: WhaleApiClientOptions diff --git a/src/module.api/_module.ts b/src/module.api/_module.ts index fa98e87e9..b0f419513 100644 --- a/src/module.api/_module.ts +++ b/src/module.api/_module.ts @@ -24,6 +24,7 @@ import { FeeController } from '@src/module.api/fee.controller' import { RawtxController } from '@src/module.api/rawtx.controller' import { LoanController } from '@src/module.api/loan.controller' import { LoanVaultService } from '@src/module.api/loan.vault.service' +import { AnchorsController } from '@src/module.api/anchors.controller' /** * Exposed ApiModule for public interfacing @@ -44,7 +45,8 @@ import { LoanVaultService } from '@src/module.api/loan.vault.service' StatsController, FeeController, RawtxController, - LoanController + LoanController, + AnchorsController ], providers: [ { provide: APP_PIPE, useClass: ApiValidationPipe }, diff --git a/src/module.api/anchors.controller.e2e.ts b/src/module.api/anchors.controller.e2e.ts new file mode 100644 index 000000000..c25ff6d9b --- /dev/null +++ b/src/module.api/anchors.controller.e2e.ts @@ -0,0 +1,173 @@ +import { MasterNodeRegTestContainer } from '@defichain/testcontainers' +import { RegTestFoundationKeys } from '@defichain/jellyfish-network' +import { NestFastifyApplication } from '@nestjs/platform-fastify' +import { createTestingApp } from '@src/e2e.module' +import { AnchorsController } from '@src/module.api/anchors.controller' +import { TestingGroup } from '@defichain/jellyfish-testing' + +let tGroup: TestingGroup +let container: MasterNodeRegTestContainer +let app: NestFastifyApplication +let controller: AnchorsController + +beforeAll(async () => { + tGroup = TestingGroup.create(3) + container = tGroup.group.get(0) + + await container.start() + await tGroup.start() + + app = await createTestingApp(container) + controller = app.get(AnchorsController) + + await setup() +}) + +afterAll(async () => { + await app.close() + await tGroup.group.stop() +}) + +async function setMockTime (offsetHour: number): Promise { + await tGroup.exec(async (testing: any) => { + await testing.misc.offsetTimeHourly(offsetHour) + }) +} + +async function setup (): Promise { + { + const auths = await tGroup.get(0).container.call('spv_listanchorauths') + expect(auths.length).toStrictEqual(0) + } + + const initOffsetHour = -12 + await setMockTime(initOffsetHour) + + for (let i = 0; i < 15; i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + await container.generate(1) + await tGroup.waitForSync() + } + + await tGroup.get(0).container.waitForAnchorTeams(tGroup.length()) + + for (let i = 0; i < tGroup.length(); i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + const team = await container.call('getanchorteams') + expect(team.auth.length).toStrictEqual(tGroup.length()) + expect(team.confirm.length).toStrictEqual(tGroup.length()) + expect(team.auth.includes(RegTestFoundationKeys[0].operator.address)) + expect(team.auth.includes(RegTestFoundationKeys[1].operator.address)) + expect(team.auth.includes(RegTestFoundationKeys[2].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[0].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[1].operator.address)) + expect(team.confirm.includes(RegTestFoundationKeys[2].operator.address)) + } + + await tGroup.anchor.generateAnchorAuths(2, initOffsetHour) + + await tGroup.get(0).container.waitForAnchorAuths(tGroup.length()) + + for (let i = 0; i < tGroup.length(); i += 1) { + const { container } = tGroup.get(i % tGroup.length()) + const auths = await container.call('spv_listanchorauths') + expect(auths.length).toStrictEqual(2) + expect(auths[0].signers).toStrictEqual(tGroup.length()) + } + + await tGroup.get(0).container.call('spv_setlastheight', [1]) + const anchor1 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [2]) + const anchor2 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [3]) + const anchor3 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(0).container.call('spv_setlastheight', [4]) + const anchor4 = await createAnchor() + await tGroup.get(0).generate(1) + + await tGroup.get(1).container.call('spv_sendrawtx', [anchor1.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor2.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor3.txHex]) + await tGroup.get(1).container.call('spv_sendrawtx', [anchor4.txHex]) + await tGroup.get(1).generate(1) + await tGroup.waitForSync() + + await tGroup.get(0).container.call('spv_setlastheight', [6]) +} + +async function createAnchor (): Promise { + const rewardAddress = await tGroup.get(0).rpc.spv.getNewAddress() + return await tGroup.get(0).rpc.spv.createAnchor([{ + txid: '11a276bb25585f6973a4dd68373cffff41dbcaddf12bbc1c2b489d1dc84564ee', + vout: 2, + amount: 15800, + privkey: 'b0528d87cfdb09f72c9d10b7b3cc00727062d93537a3e8abcf1fde821d08b59d' + }], rewardAddress) +} + +describe('list', () => { + it('should list anchors = 4', async () => { + const response = await controller.list({ size: 4 }) + expect(response.data.length).toStrictEqual(4) + + expect(response.data[0]).toStrictEqual({ + id: '4', + btc: { + block: { + height: 4, + hash: '0000000000000001000000000000000100000000000000010000000000000001' + }, + txn: { + hash: expect.any(String) + }, + confirmations: 3 + }, + dfi: { + block: { + height: 30, + hash: expect.any(String) + } + }, + previousAnchor: '0000000000000000000000000000000000000000000000000000000000000000', + rewardAddress: expect.any(String), + signatures: 2, + active: false, + anchorCreationHeight: 90 + }) + }) + + it('should test pagination with maxBtcHeight', async () => { + const firstRequest = await controller.list({ size: 1 }) + expect(firstRequest.data.length).toStrictEqual(1) + expect(firstRequest.page?.next).toStrictEqual('3') + + const secondRequest = await controller.list({ + size: 1, + next: + firstRequest.page?.next + }) + expect(secondRequest.data.length).toStrictEqual(1) + expect(secondRequest.page?.next).toStrictEqual('2') + + const thirdRequest = await controller.list({ + size: 1, + next: secondRequest.page?.next + }) + expect(thirdRequest.data.length).toStrictEqual(1) + expect(thirdRequest.page?.next).toStrictEqual('1') + + const lastRequest = await controller.list({ + size: 2, + next: thirdRequest.page?.next + }) + + expect(lastRequest.data.length).toStrictEqual(1) + expect(lastRequest.page?.next).toStrictEqual(undefined) + }) +}) diff --git a/src/module.api/anchors.controller.ts b/src/module.api/anchors.controller.ts new file mode 100644 index 000000000..83cd07224 --- /dev/null +++ b/src/module.api/anchors.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Query } from '@nestjs/common' +import { ListAnchorsResult } from '@defichain/jellyfish-api-core/dist/category/spv' +import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' +import { AnchorData } from '@whale-api-client/api/anchors' +import { PaginationQuery } from '@src/module.api/_core/api.query' +import { ApiPagedResponse } from '@src/module.api/_core/api.paged.response' + +@Controller('/anchors') +export class AnchorsController { + constructor ( + protected readonly rpcClient: JsonRpcClient + ) { + } + + /** + * List Anchors + * + * @param {PaginationQuery} query + * @param {number} query.size query limit size. Default = 30 + * @param {string} [query.next] max BTC height + * @return {Promise>} + */ + @Get('') + async list ( + @Query() query: PaginationQuery): Promise> { + const result = await this.rpcClient.spv.listAnchors({ + limit: Number(query.size), + ...(query.next !== undefined) && { maxBtcHeight: Number(query.next) } + }) + + const anchors = result + .map((anchor) => { + return mapAnchors(anchor) + }) + + return ApiPagedResponse.of(anchors, query.size, item => (Number(item.id) - 1).toString()) + } +} + +function mapAnchors (anchor: ListAnchorsResult): AnchorData { + return { + id: anchor.btcBlockHeight.toString(), + btc: { + block: { + height: anchor.btcBlockHeight, + hash: anchor.btcBlockHash + }, + txn: { + hash: anchor.btcTxHash + }, + confirmations: anchor.confirmations + }, + dfi: { + block: { + height: anchor.defiBlockHeight, + hash: anchor.defiBlockHash + } + }, + previousAnchor: anchor.previousAnchor, + rewardAddress: anchor.rewardAddress, + signatures: anchor.signatures, + active: anchor.active, + anchorCreationHeight: anchor.anchorCreationHeight + } +}