diff --git a/.idea/dictionaries/fuxing.xml b/.idea/dictionaries/fuxing.xml index b831359a1..ab9d93f85 100644 --- a/.idea/dictionaries/fuxing.xml +++ b/.idea/dictionaries/fuxing.xml @@ -135,6 +135,7 @@ pooledtx poolpair poolpairs + poolswap previousblockhash prevout prevouts diff --git a/packages/whale-api-client/__tests__/api/poolpairs.test.ts b/packages/whale-api-client/__tests__/api/poolpairs.test.ts index 9c0f4e18c..d5da08c53 100644 --- a/packages/whale-api-client/__tests__/api/poolpairs.test.ts +++ b/packages/whale-api-client/__tests__/api/poolpairs.test.ts @@ -3,7 +3,7 @@ import { StubWhaleApiClient } from '../stub.client' import { StubService } from '../stub.service' import { ApiPagedResponse, WhaleApiClient, WhaleApiException } from '../../src' import { addPoolLiquidity, createPoolPair, createToken, getNewAddress, mintTokens, poolSwap } from '@defichain/testing' -import { PoolPairData, PoolSwap, PoolSwapAggregated, PoolSwapAggregatedInterval } from '../../src/api/poolpairs' +import { PoolPairData, PoolSwapAggregatedData, PoolSwapAggregatedInterval, PoolSwapData } from '../../src/api/poolpairs' import { Testing } from '@defichain/jellyfish-testing' let container: MasterNodeRegTestContainer @@ -371,13 +371,95 @@ describe('poolswap', () => { await container.generate(1) await service.waitForIndexedHeight(height) - const response: ApiPagedResponse = await client.poolpairs.listPoolSwaps('9') - expect(response.length).toStrictEqual(2) + const response: ApiPagedResponse = await client.poolpairs.listPoolSwaps('9') expect(response.hasNext).toStrictEqual(false) - expect(response[0].fromAmount).toStrictEqual('50.00000000') - expect(response[1].fromAmount).toStrictEqual('25.00000000') - expect(response[0].fromTokenId).toStrictEqual(1) - expect(response[1].fromTokenId).toStrictEqual(1) + expect([...response]).toStrictEqual([ + { + id: expect.any(String), + txid: expect.stringMatching(/[0-f]{64}/), + txno: 1, + poolPairId: '9', + sort: expect.any(String), + fromAmount: '50.00000000', + fromTokenId: 1, + block: { + hash: expect.stringMatching(/[0-f]{64}/), + height: expect.any(Number), + time: expect.any(Number), + medianTime: expect.any(Number) + } + }, + { + id: expect.any(String), + txid: expect.stringMatching(/[0-f]{64}/), + txno: 3, + poolPairId: '9', + sort: expect.any(String), + fromAmount: '25.00000000', + fromTokenId: 1, + block: { + hash: expect.stringMatching(/[0-f]{64}/), + height: expect.any(Number), + time: expect.any(Number), + medianTime: expect.any(Number) + } + } + ]) + + const verbose: ApiPagedResponse = await client.poolpairs.listPoolSwapsVerbose('9') + expect(verbose.hasNext).toStrictEqual(false) + expect([...verbose]).toStrictEqual([ + { + id: expect.any(String), + txid: expect.stringMatching(/[0-f]{64}/), + txno: 1, + poolPairId: '9', + sort: expect.any(String), + fromAmount: '50.00000000', + fromTokenId: 1, + block: { + hash: expect.stringMatching(/[0-f]{64}/), + height: expect.any(Number), + time: expect.any(Number), + medianTime: expect.any(Number) + }, + from: { + address: expect.any(String), + symbol: 'A', + amount: '50.00000000' + }, + to: { + address: expect.any(String), + amount: '45.71428571', + symbol: 'DFI' + } + }, + { + id: expect.any(String), + txid: expect.stringMatching(/[0-f]{64}/), + txno: 3, + poolPairId: '9', + sort: expect.any(String), + fromAmount: '25.00000000', + fromTokenId: 1, + block: { + hash: expect.stringMatching(/[0-f]{64}/), + height: expect.any(Number), + time: expect.any(Number), + medianTime: expect.any(Number) + }, + from: { + address: expect.any(String), + symbol: 'A', + amount: '25.00000000' + }, + to: { + address: expect.any(String), + amount: '39.99999999', + symbol: 'DFI' + } + } + ]) const poolPair: PoolPairData = await client.poolpairs.get('9') expect(poolPair).toStrictEqual({ @@ -594,7 +676,7 @@ describe('poolswap aggregated', () => { await service.waitForIndexedHeight(height) } - const dayAggregated: ApiPagedResponse = await client.poolpairs.listPoolSwapAggregates('10', PoolSwapAggregatedInterval.ONE_DAY, 10) + const dayAggregated: ApiPagedResponse = await client.poolpairs.listPoolSwapAggregates('10', PoolSwapAggregatedInterval.ONE_DAY, 10) expect([...dayAggregated]).toStrictEqual([ { aggregated: { @@ -631,7 +713,7 @@ describe('poolswap aggregated', () => { ]) - const hourAggregated: ApiPagedResponse = await client.poolpairs.listPoolSwapAggregates('10', PoolSwapAggregatedInterval.ONE_HOUR, 3) + const hourAggregated: ApiPagedResponse = await client.poolpairs.listPoolSwapAggregates('10', PoolSwapAggregatedInterval.ONE_HOUR, 3) expect([...hourAggregated]).toStrictEqual([ { aggregated: { diff --git a/packages/whale-api-client/src/api/poolpairs.ts b/packages/whale-api-client/src/api/poolpairs.ts index 8de76fe40..b71a68679 100644 --- a/packages/whale-api-client/src/api/poolpairs.ts +++ b/packages/whale-api-client/src/api/poolpairs.ts @@ -35,12 +35,24 @@ export class PoolPairs { * @param {string} id poolpair id * @param {number} size of PoolSwap to query * @param {string} next set of PoolSwap - * @return {Promise>} + * @return {Promise>} */ - async listPoolSwaps (id: string, size: number = 30, next?: string): Promise> { + async listPoolSwaps (id: string, size: number = 30, next?: string): Promise> { return await this.client.requestList('GET', `poolpairs/${id}/swaps`, size, next) } + /** + * List pool swaps with from/to + * + * @param {string} id poolpair id + * @param {number} [size=10] of PoolSwap to query, max of 20 per page + * @param {string} next set of PoolSwap + * @return {Promise>} + */ + async listPoolSwapsVerbose (id: string, size: number = 10, next?: string): Promise> { + return await this.client.requestList('GET', `poolpairs/${id}/swaps/verbose`, size, next) + } + /** * List pool swap aggregates * @@ -48,9 +60,9 @@ export class PoolPairs { * @param {PoolSwapAggregatedInterval} interval interval * @param {number} size of PoolSwap to query * @param {string} next set of PoolSwap - * @return {Promise>} + * @return {Promise>} */ - async listPoolSwapAggregates (id: string, interval: PoolSwapAggregatedInterval, size: number = 30, next?: string): Promise> { + async listPoolSwapAggregates (id: string, interval: PoolSwapAggregatedInterval, size: number = 30, next?: string): Promise> { return await this.client.requestList('GET', `poolpairs/${id}/swaps/aggregate/${interval as number}`, size, next) } } @@ -103,7 +115,17 @@ export interface PoolPairData { } } -export interface PoolSwap { +/** + * @deprecated use PoolSwapData instead + */ +export type PoolSwap = PoolSwapData + +/** + * @deprecated use PoolSwapAggregatedData instead + */ +export type PoolSwapAggregated = PoolSwapAggregatedData + +export interface PoolSwapData { id: string sort: string txid: string @@ -113,6 +135,15 @@ export interface PoolSwap { fromAmount: string fromTokenId: number + /** + * To handle for optional value as Whale service might fail to resolve when indexing + */ + from?: PoolSwapFromToData + /** + * To handle for optional value as Whale service might fail to resolve when indexing + */ + to?: PoolSwapFromToData + block: { hash: string height: number @@ -121,7 +152,13 @@ export interface PoolSwap { } } -export interface PoolSwapAggregated { +export interface PoolSwapFromToData { + address: string + amount: string + symbol: string +} + +export interface PoolSwapAggregatedData { id: string key: string bucket: number diff --git a/src/module.api/poolpair.controller.ts b/src/module.api/poolpair.controller.ts index 091e49024..e9f15386b 100644 --- a/src/module.api/poolpair.controller.ts +++ b/src/module.api/poolpair.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from ' import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' import { ApiPagedResponse } from '@src/module.api/_core/api.paged.response' import { DeFiDCache } from '@src/module.api/cache/defid.cache' -import { PoolPairData, PoolSwap, PoolSwapAggregated } from '@whale-api-client/api/poolpairs' +import { PoolPairData, PoolSwapAggregatedData, PoolSwapData } from '@whale-api-client/api/poolpairs' import { PaginationQuery } from '@src/module.api/_core/api.query' import { PoolPairService } from './poolpair.service' import BigNumber from 'bignumber.js' @@ -83,9 +83,35 @@ export class PoolPairController { async listPoolSwaps ( @Param('id', ParseIntPipe) id: string, @Query() query: PaginationQuery - ): Promise> { - const result = await this.poolSwapMapper.query(id, query.size, query.next) - return ApiPagedResponse.of(result, query.size, item => { + ): Promise> { + const items = await this.poolSwapMapper.query(id, query.size, query.next) + return ApiPagedResponse.of(items, query.size, item => { + return item.sort + }) + } + + /** + * @param {string} id poolpair id + * @param {PaginationQuery} query with size restricted to 20 + * @param {number} query.size + * @param {string} [query.next] + * @return {Promise>} + */ + @Get('/:id/swaps/verbose') + async listPoolSwapsVerbose ( + @Param('id', ParseIntPipe) id: string, + @Query() query: PaginationQuery + ): Promise> { + query.size = query.size > 20 ? 20 : query.size + const items: PoolSwapData[] = await this.poolSwapMapper.query(id, query.size, query.next) + + for (const swap of items) { + const fromTo = await this.poolPairService.findSwapFromTo(swap.block.height, swap.txid, swap.txno) + swap.from = fromTo?.from + swap.to = fromTo?.to + } + + return ApiPagedResponse.of(items, query.size, item => { return item.sort }) } @@ -106,10 +132,10 @@ export class PoolPairController { @Param('id', ParseIntPipe) id: string, @Param('interval', ParseIntPipe) interval: string, @Query() query: PaginationQuery - ): Promise> { + ): Promise> { const lt = query.next === undefined ? undefined : parseInt(query.next) const aggregates = await this.poolSwapAggregatedMapper.query(`${id}-${interval}`, query.size, lt) - const mapped: Array> = aggregates.map(async value => { + const mapped: Array> = aggregates.map(async value => { return { ...value, aggregated: { @@ -126,8 +152,7 @@ export class PoolPairController { } } -function mapPoolPair (id: string, info: PoolPairInfo, totalLiquidityUsd?: BigNumber, apr?: PoolPairData['apr'], - volume?: PoolPairData['volume']): PoolPairData { +function mapPoolPair (id: string, info: PoolPairInfo, totalLiquidityUsd?: BigNumber, apr?: PoolPairData['apr'], volume?: PoolPairData['volume']): PoolPairData { const [symbolA, symbolB] = info.symbol.split('-') return { diff --git a/src/module.api/poolpair.service.ts b/src/module.api/poolpair.service.ts index 20cfb988e..cfe3a1048 100644 --- a/src/module.api/poolpair.service.ts +++ b/src/module.api/poolpair.service.ts @@ -1,21 +1,38 @@ -import { Injectable, NotFoundException } from '@nestjs/common' +import { Inject, Injectable, NotFoundException } from '@nestjs/common' import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc' import BigNumber from 'bignumber.js' import { PoolPairInfo } from '@defichain/jellyfish-api-core/dist/category/poolpair' import { SemaphoreCache } from '@src/module.api/cache/semaphore.cache' -import { PoolPairData } from '@whale-api-client/api/poolpairs' +import { PoolPairData, PoolSwapFromToData } from '@whale-api-client/api/poolpairs' import { getBlockSubsidy } from '@src/module.api/subsidy' import { BlockMapper } from '@src/module.model/block' import { TokenMapper } from '@src/module.model/token' import { PoolSwapAggregated, PoolSwapAggregatedMapper } from '@src/module.model/pool.swap.aggregated' import { PoolSwapAggregatedInterval } from '@src/module.indexer/model/dftx/pool.swap.aggregated' +import { TransactionVout, TransactionVoutMapper } from '@src/module.model/transaction.vout' +import { SmartBuffer } from 'smart-buffer' +import { + CCompositeSwap, + CompositeSwap, + CPoolSwap, + OP_DEFI_TX, + PoolSwap as PoolSwapDfTx, + toOPCodes +} from '@defichain/jellyfish-transaction' +import { fromScript } from '@defichain/jellyfish-address' +import { NetworkName } from '@defichain/jellyfish-network' +import { AccountHistory } from '@defichain/jellyfish-api-core/dist/category/account' +import { DeFiDCache } from '@src/module.api/cache/defid.cache' @Injectable() export class PoolPairService { constructor ( + @Inject('NETWORK') protected readonly network: NetworkName, protected readonly rpcClient: JsonRpcClient, + protected readonly deFiDCache: DeFiDCache, protected readonly cache: SemaphoreCache, protected readonly poolSwapAggregatedMapper: PoolSwapAggregatedMapper, + protected readonly voutMapper: TransactionVoutMapper, protected readonly tokenMapper: TokenMapper, protected readonly blockMapper: BlockMapper ) { @@ -202,6 +219,40 @@ export class PoolPairService { return value } + public async findSwapFromTo (height: number, txid: string, txno: number): Promise<{ from?: PoolSwapFromToData, to?: PoolSwapFromToData } | undefined> { + const vouts = await this.voutMapper.query(txid, 1) + const dftx = findPoolSwapDfTx(vouts) + if (dftx === undefined) { + return undefined + } + + const fromAddress = fromScript(dftx.fromScript, this.network)?.address + const fromToken = await this.deFiDCache.getTokenInfo(dftx.fromTokenId.toString()) + + const toAddress = fromScript(dftx.toScript, this.network)?.address + if (fromAddress === undefined || toAddress === undefined || fromToken === undefined) { + return undefined + } + + const history = await this.getAccountHistory(toAddress, height, txno) + if (history === undefined) { + return undefined + } + + return { + from: { + address: fromAddress, + symbol: fromToken.symbol, + amount: dftx.fromAmount.toFixed(8) + }, + to: findPoolSwapFromTo(history, false) + } + } + + private async getAccountHistory (address: string, height: number, txno: number): Promise { + return await this.rpcClient.account.getAccountHistory(address, height, txno) + } + private async getLoanTokenSplits (): Promise | undefined> { return await this.cache.get>('LP_LOAN_TOKEN_SPLITS', async () => { const result = await this.rpcClient.masternode.getGov('LP_LOAN_TOKEN_SPLITS') @@ -324,3 +375,53 @@ export class PoolPairService { } } } + +function findPoolSwapDfTx (vouts: TransactionVout[]): PoolSwapDfTx | undefined { + const hex = vouts[0].script.hex + const buffer = SmartBuffer.fromBuffer(Buffer.from(hex, 'hex')) + const stack = toOPCodes(buffer) + if (stack.length !== 2 || stack[1].type !== 'OP_DEFI_TX') { + return undefined + } + + const dftx = (stack[1] as OP_DEFI_TX).tx + if (dftx === undefined) { + return undefined + } + + switch (dftx.name) { + case CPoolSwap.OP_NAME: + return (dftx.data as PoolSwapDfTx) + + case CCompositeSwap.OP_NAME: + return (dftx.data as CompositeSwap).poolSwap + + default: + return undefined + } +} + +function findPoolSwapFromTo (history: AccountHistory, from: boolean): PoolSwapFromToData | undefined { + for (const amount of history.amounts) { + const [value, symbol] = amount.split('@') + const isNegative = value.startsWith('-') + + if (isNegative && from) { + return { + address: history.owner, + amount: new BigNumber(value).absoluteValue().toFixed(8), + symbol: symbol + } + } + + if (!isNegative && !from) { + return { + address: history.owner, + amount: new BigNumber(value).absoluteValue().toFixed(8), + symbol: symbol + } + } + } + + return undefined +}