apps/whale-api/src/module.api/address.controller.ts
import BigNumber from 'bignumber.js'
import { BadRequestException, ConflictException, Controller, ForbiddenException, Get, Inject, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common'
import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc'
import { ApiPagedResponse } from './_core/api.paged.response'
import { DeFiDCache } from './cache/defid.cache'
import { TokenInfo } from '@defichain/jellyfish-api-core/dist/category/token'
import { AddressToken, AddressHistory } from '@defichain/whale-api-client/dist/api/address'
import { PaginationQuery } from './_core/api.query'
import { ScriptActivity, ScriptActivityMapper } from '../module.model/script.activity'
import { ScriptAggregation, ScriptAggregationMapper } from '../module.model/script.aggregation'
import { ScriptUnspent, ScriptUnspentMapper } from '../module.model/script.unspent'
import { DeFiAddress } from '@defichain/jellyfish-address'
import { NetworkName } from '@defichain/jellyfish-network'
import { HexEncoder } from '../module.model/_hex.encoder'
import { toBuffer } from '@defichain/jellyfish-transaction/dist/script/_buffer'
import { LoanVaultActive, LoanVaultLiquidated } from '@defichain/whale-api-client/dist/api/loan'
import { LoanVaultService } from './loan.vault.service'
import { parseDisplaySymbol } from './token.controller'
import { AccountHistory } from '@defichain/jellyfish-api-core/dist/category/account'
@Controller('/address/:address')
export class AddressController {
constructor (
protected readonly rpcClient: JsonRpcClient,
protected readonly deFiDCache: DeFiDCache,
protected readonly aggregationMapper: ScriptAggregationMapper,
protected readonly activityMapper: ScriptActivityMapper,
protected readonly unspentMapper: ScriptUnspentMapper,
protected readonly vaultService: LoanVaultService,
@Inject('NETWORK') protected readonly network: NetworkName
) {
}
@Get('/history/:height/:txno')
async getAccountHistory (
@Param('address') address: string,
@Param('height', ParseIntPipe) height: number,
@Param('txno', ParseIntPipe) txno: number
): Promise<AddressHistory> {
try {
const accountHistory = await this.rpcClient.account.getAccountHistory(address, height, txno)
if (Object.keys(accountHistory).length === 0) {
throw new NotFoundException('Record not found')
}
return mapAddressHistory(accountHistory)
} catch (err) {
if (err instanceof NotFoundException) {
throw err
}
throw new BadRequestException(err)
}
}
/**
* @param {string} address to list participate account history
* @param {PaginationQuery} query
*/
@Get('/history')
async listAccountHistory (
@Param('address') address: string,
@Query() query: PaginationQuery): Promise<ApiPagedResponse<AddressHistory>> {
if (address === 'mine') {
throw new ForbiddenException('mine is not allowed')
}
const limit = query.size > 200 ? 200 : query.size
const next = query.next ?? undefined
let list: AccountHistory[]
if (next !== undefined) {
const [txid, txType, maxBlockHeight] = next.split('-')
/**
* Demostrate workaround the `loop`
* full: [A50, B50, C50, D50, E50, F50, G50, H50, I15, J10, K05, L01]
*
* | action | limit | max | output | sliced |
* |--------|-------|-----|--------------------------------|-----------------|
* | | 3 | | [A50, B50, C50] | |
* | next1 | 3 | 50 | [A50, B50, C50] | [ ] |
* | loop1 | 6 | 50 | [A50, B50, C50, D50, E50, F50] | [D50, E50, F50] |
* | next2 | 3 | 50 | [A50, B50, C50] | [ ] |
* | loop2 | 6 | 50 | [A50, B50, C50, D50, E50, F50] | [ ] |
* | loop3 | 12 | 50 | [A50, B50, C50, D50, E50, F50, | [G50, H50, I15] |
* | | | | G50, H50, I15, J10, K05, L01] | |
* | next3 | 3 | 15 | [I15, J10, K05] | [J10, K05] |
* | loop4 | 6 | 15 | [I15, J10, K05, L01] | [J10, K05, L01] |
*
* Found **last batch** on loop4 as its `list.length !== limit`.
*/
const loop = async (maxBlockHeight: number, limit: number): Promise<AccountHistory[]> => {
const list = await this.rpcClient.account.listAccountHistory(address, {
limit: limit,
maxBlockHeight: maxBlockHeight,
no_rewards: true
})
if (list.length === 0) {
return []
}
const isLastBatch = list.length !== limit
const foundIndex = list.findIndex(each => each.txid === txid && each.type === txType)
if (foundIndex === -1) {
// if not found, extend the size till grab the 'next'
return await loop(Number(maxBlockHeight), limit * 2)
}
const start = foundIndex + 1 // plus 1 to exclude the prev txid
const size = start + query.size
const sliced = list.slice(start, size)
if (sliced.length !== query.size && !isLastBatch) {
// need a bigger volume to achieve the size
return await loop(Number(maxBlockHeight), limit * 2)
}
return sliced
}
list = await loop(Number(maxBlockHeight), limit)
} else {
list = await this.rpcClient.account.listAccountHistory(address, {
limit: limit,
no_rewards: true
})
}
const history = list.map(each => mapAddressHistory(each))
return ApiPagedResponse.of(history, query.size, item => {
return `${item.txid}-${item.type}-${item.block.height}`
})
}
@Get('/balance')
async getBalance (@Param('address') address: string): Promise<string> {
const aggregation = await this.getAggregation(address)
return aggregation?.amount.unspent ?? '0.00000000'
}
@Get('/aggregation')
async getAggregation (@Param('address') address: string): Promise<ScriptAggregation | undefined> {
const hid = addressToHid(this.network, address)
return await this.aggregationMapper.getLatest(hid)
}
/**
* @param {string} address to list tokens belonging to address
* @param {PaginationQuery} query
*/
@Get('/tokens')
async listTokens (
@Param('address') address: string,
@Query() query: PaginationQuery
): Promise<ApiPagedResponse<AddressToken>> {
const accounts = await this.rpcClient.account.getAccount(address, {
start: query.next !== undefined ? Number(query.next) : undefined,
including_start: query.next === undefined, // TODO(fuxingloh): open issue at DeFiCh/ain, rpc_accounts.cpp#388
limit: query.size
}, { indexedAmounts: true })
const ids = Object.keys(accounts)
const tokenInfos = await this.deFiDCache.batchTokenInfo(ids)
const tokens: AddressToken[] = Object.entries(accounts)
.map(([id, value]): AddressToken => {
const tokenInfo = tokenInfos[id]
if (tokenInfo === undefined) {
throw new ConflictException('unable to find token')
}
return mapAddressToken(id, tokenInfo, value)
}).sort(a => Number.parseInt(a.id))
return ApiPagedResponse.of(tokens, query.size, item => {
return item.id
})
}
@Get('/vaults')
async listVaults (
@Param('address') address: string,
@Query() query: PaginationQuery
): Promise<ApiPagedResponse<LoanVaultActive | LoanVaultLiquidated>> {
return await this.vaultService.list(query, address)
}
@Get('/transactions')
async listTransactions (
@Param('address') address: string,
@Query() query: PaginationQuery
): Promise<ApiPagedResponse<ScriptActivity>> {
const hid = addressToHid(this.network, address)
const items = await this.activityMapper.query(hid, query.size, query.next)
return ApiPagedResponse.of(items, query.size, item => {
return item.id
})
}
@Get('/transactions/unspent')
async listTransactionsUnspent (
@Param('address') address: string,
@Query() query: PaginationQuery
): Promise<ApiPagedResponse<ScriptUnspent>> {
const hid = addressToHid(this.network, address)
const items = await this.unspentMapper.query(hid, query.size, query.next)
return ApiPagedResponse.of(items, query.size, item => {
return item.sort
})
}
}
/**
* @param {NetworkName} name of the network
* @param {string} address to convert to HID
* @return {string} HID is hashed script.hex, SHA256(decodeAddress(address).hex)
*/
export function addressToHid (name: NetworkName, address: string): string {
// TODO(fuxingloh): refactor jellyfish-address, then refactor this
const decoded = DeFiAddress.from(name, address)
const stack = decoded.getScript().stack
const hex = toBuffer(stack).toString('hex')
return HexEncoder.asSHA256(hex)
}
function mapAddressToken (id: string, tokenInfo: TokenInfo, value: BigNumber): AddressToken {
return {
id: id,
amount: value.toFixed(8),
symbol: tokenInfo.symbol,
symbolKey: tokenInfo.symbolKey,
name: tokenInfo.name,
isDAT: tokenInfo.isDAT,
isLPS: tokenInfo.isLPS,
isLoanToken: tokenInfo.isLoanToken,
displaySymbol: parseDisplaySymbol(tokenInfo)
}
}
function mapAddressHistory (history: AccountHistory): AddressHistory {
return {
owner: history.owner,
txid: history.txid,
txn: history.txn,
type: history.type,
amounts: history.amounts,
block: {
height: history.blockHeight,
hash: history.blockHash,
time: history.blockTime
}
}
}