DeFiCh/jellyfish

View on GitHub
apps/whale-api/src/module.api/legacy.subgraph.service.ts

Summary

Maintainability
C
1 day
Test Coverage
import { parseHeight } from './block.controller'
import { BlockTxn, encodeBase64 } from '../../../legacy-api/src/controllers/PoolPairController'
import { Block } from '@defichain/whale-api-client/dist/api/blocks'
import { Transaction, TransactionVout } from '@defichain/whale-api-client/dist/api/transactions'
import {
  CCompositeSwap,
  CompositeSwap,
  CPoolSwap,
  OP_DEFI_TX,
  PoolSwap,
  toOPCodes
} from '@defichain/jellyfish-transaction'
import { fromScript } from '@defichain/jellyfish-address'
import { AccountHistory } from '@defichain/jellyfish-api-core/dist/category/account'
import { JsonRpcClient } from '@defichain/jellyfish-api-jsonrpc'
import { BlockMapper } from '../module.model/block'
import { TransactionMapper } from '../module.model/transaction'
import { TransactionVoutMapper } from '../module.model/transaction.vout'
import { BigNumber } from 'bignumber.js'
import { SmartBuffer } from 'smart-buffer'
import { Injectable, Logger } from '@nestjs/common'
import { requireValue } from './stats.controller'
import { NetworkName } from '@defichain/jellyfish-network'
import { LegacyCache } from '@defichain-apps/libs/caches'

@Injectable()
export class LegacySubgraphService {
  private readonly logger = new Logger(LegacySubgraphService.name)

  constructor (
    protected readonly rpcClient: JsonRpcClient,
    protected readonly blockMapper: BlockMapper,
    protected readonly transactionMapper: TransactionMapper,
    protected readonly transactionVoutMapper: TransactionVoutMapper,
    private readonly cache: LegacyCache
  ) {
  }

  decodeNextToken (nextString: string): NextToken {
    return JSON.parse(Buffer.from(nextString, 'base64url').toString())
  }

  encodeNextToken (next: NextToken): string {
    return encodeBase64(next)
  }

  async getSwapsHistory (
    network: NetworkName,
    limit: number,
    nextToken: NextToken
  ): Promise<{ swaps: LegacySubgraphSwap[], next: NextToken }> {
    const allSwaps: LegacySubgraphSwap[] = []

    while (allSwaps.length <= limit) {
      const height = parseHeight(nextToken?.height)

      for (const block of await this.blockMapper.queryByHeight(200, height)) {
        let blockTxns: BlockTxn[]

        if (await this.isRecentBlock(block)) {
          blockTxns = await this.getBlockTransactionsWithNonSwapsAsNull(block, network, nextToken)
        } else {
          blockTxns = await this.cache.get<BlockTxn[]>(
            block.hash,
            async () => {
              const cached = await this.getBlockTransactionsWithNonSwapsAsNull(block, network, nextToken)
              if (cached.length > 0) {
                const swapCount = cached.reduce(
                  (count, txn) => txn.swap === null ? count : count + 1,
                  0
                )
                this.logger.log(`[${network}] Block ${block.height} - cached ${swapCount} swaps`)
              }
              return cached
            },
            {
              ttl: 24 * 60 * 60
            }
          ) ?? []
        }

        for (let i = Number(nextToken.order ?? '0'); i < blockTxns.length; i++) {
          const blockTxn = blockTxns[i]
          if (blockTxn.swap === null) {
            continue
          }
          allSwaps.push(blockTxn.swap)

          const isLastSwapInBlock = ((i + 1) === blockTxns.length)
          if (isLastSwapInBlock) {
            // Pagination fix: since this is the last swap in the block, we will point
            // the pagination cursor to skip the current block. We also reset 'order' to 0
            // (the start of the block).
            nextToken = {
              height: block.height.toString(),
              order: '0'
            }
          } else {
            // Pagination fix: since this isn't the last pool swap in the block,
            // we move the cursor to the previous block (1 above), as there might still be
            // pool swaps in the current block, and we don't want to skip them (setting
            // the next.height value to N will result in block N being skipped).
            nextToken = {
              height: (blockTxn.height + 1).toString(),
              order: blockTxn.order.toString()
            }
          }

          if (allSwaps.length === limit) {
            this.logger.debug(`[${network}] Block ${block.height} - pagination ${JSON.stringify(nextToken)}`)
            return {
              swaps: allSwaps,
              next: nextToken
            }
          }
        }

        // Move cursor to next block, as we're done with it
        nextToken = {
          height: block.height.toString(),
          order: '0'
        }
      }
    }
    // Highly unlikely to reach here, but for completeness
    return {
      swaps: allSwaps,
      next: nextToken
    }
  }

  private async getBlockTransactionsWithNonSwapsAsNull (
    block: Block,
    network: NetworkName,
    next: NextToken
  ): Promise<BlockTxn[]> {
    const swaps: BlockTxn[] = []

    for (const transaction of await this.transactionMapper.queryByBlockHash(block.hash, 200, parseHeight(next?.order))) {
      const swap = await this.getSwapFromTransaction(transaction, network)
      swaps.push({
        swap: swap,
        height: transaction.block.height,
        order: transaction.order
      })
    }
    return swaps
  }

  private async getSwapFromTransaction (transaction: Transaction, network: NetworkName): Promise<LegacySubgraphSwap | null> {
    return await this.cache.get<LegacySubgraphSwap | null>(transaction.txid, async () => {
      if (transaction.voutCount !== 2) {
        return null
      }
      if (transaction.weight === 605) {
        return null
      }

      const vouts = await this.transactionVoutMapper.query(transaction.txid, 1)
      const dftx = this.findPoolSwapDfTx(vouts)
      if (dftx === undefined) {
        return null
      }

      const swap = await this.findSwap(network, dftx, transaction)
      if (swap === undefined) {
        return null
      }

      return swap
    }, {
      ttl: 24 * 60 * 60
    }) ?? null
  }

  private async findSwap (network: NetworkName, poolSwap: PoolSwap, transaction: Transaction): Promise<LegacySubgraphSwap | undefined> {
    const fromAddress = fromScript(poolSwap.fromScript, network)?.address
    const toAddress = fromScript(poolSwap.toScript, network)?.address

    if (
      toAddress === undefined || toAddress === '' ||
      fromAddress === undefined || fromAddress === ''
    ) {
      return undefined
    }

    const fromHistory = await this.rpcClient.account.getAccountHistory(fromAddress, transaction.block.height, transaction.order)
    let toHistory: AccountHistory
    if (toAddress === fromAddress) {
      toHistory = fromHistory
    } else {
      toHistory = await this.rpcClient.account.getAccountHistory(toAddress, transaction.block.height, transaction.order)
    }

    const from = this.findAmountSymbol(fromHistory, true)
    const to = this.findAmountSymbol(toHistory, false)

    if (from === undefined || to === undefined) {
      return undefined
    }

    return {
      id: transaction.txid,
      timestamp: transaction.block.medianTime.toString(),
      from: from,
      to: to
    }
  }

  private async isRecentBlock (block: Block): Promise<boolean> {
    // TODO(): Can consider using MasternodeStatsMapper.getLatest()
    const chainHeight = requireValue(await this.blockMapper.getHighest(), 'block').height
    return chainHeight - block.height <= 2
  }

  private findPoolSwapDfTx (vouts: TransactionVout[]): PoolSwap | undefined {
    if (vouts.length === 0) {
      return undefined // reject because not yet indexed, cannot be found
    }

    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 PoolSwap)

      case CCompositeSwap.OP_NAME:
        return (dftx.data as CompositeSwap).poolSwap

      default:
        return undefined
    }
  }

  private findAmountSymbol (history: AccountHistory, outgoing: boolean): LegacySubgraphSwapFromTo | undefined {
    for (const amount of history.amounts ?? []) {
      const [value, symbol] = amount.split('@')
      const isNegative = value.startsWith('-')

      if (isNegative && outgoing) {
        return {
          amount: new BigNumber(value).absoluteValue().toFixed(8),
          symbol: symbol
        }
      }

      if (!isNegative && !outgoing) {
        return {
          amount: new BigNumber(value).absoluteValue().toFixed(8),
          symbol: symbol
        }
      }
    }

    return undefined
  }
}

export interface NextToken {
  height?: string
  order?: string
}

export interface LegacySubgraphSwap {
  id: string
  timestamp: string
  from: LegacySubgraphSwapFromTo
  to: LegacySubgraphSwapFromTo
}

export interface LegacySubgraphSwapFromTo {
  amount: string
  symbol: string
}

export interface LegacySubgraphSwapsResponse {
  data: {
    swaps: LegacySubgraphSwap[]
  }
  page?: {
    next: string
  }
}