DeFiCh/jellyfish

View on GitHub
apps/whale-api/src/module.api/poolswap.pathfinding.service.ts

Summary

Maintainability
B
6 hrs
Test Coverage
import { Inject, Injectable, NotFoundException } from '@nestjs/common'
import { UndirectedGraph } from 'graphology'
import {
  AllSwappableTokensResult,
  BestSwapPathResult,
  SwapPathPoolPair,
  SwapPathsResult,
  TokenIdentifier
} from '@defichain/whale-api-client/dist/api/poolpairs'
import { DeFiDCache, PoolPairInfoWithId } from './cache/defid.cache'
import { Interval } from '@nestjs/schedule'
import BigNumber from 'bignumber.js'
import { allSimplePaths } from 'graphology-simple-path'
import { parseDisplaySymbol } from './token.controller'
import { PoolPairInfo } from '@defichain/jellyfish-api-core/dist/category/poolpair'
import { connectedComponents } from 'graphology-components'
import { NetworkName } from '@defichain/jellyfish-network'
import { PoolPairFeesService } from './poolpair.fees.service'

@Injectable()
export class PoolSwapPathFindingService {
  tokenGraph: UndirectedGraph = new UndirectedGraph()
  tokensToSwappableTokens = new Map<TokenIdentifier['id'], TokenIdentifier[]>()

  constructor (
    protected readonly deFiDCache: DeFiDCache,
    private readonly poolPairFeesServices: PoolPairFeesService,
    @Inject('NETWORK') protected readonly network: NetworkName
  ) {
  }

  @Interval(120_000) // 120s
  async syncTokenGraph (): Promise<void> {
    const poolPairs = await this.deFiDCache.getPoolPairs()
    await this.addTokensAndConnectionsToGraph(poolPairs)
    await this.updateTokensToSwappableTokens()
  }

  async getAllSwappableTokens (tokenId: string): Promise<AllSwappableTokensResult> {
    await this.syncTokenGraphIfEmpty()

    return {
      fromToken: await this.getTokenIdentifier(tokenId),
      swappableTokens: this.tokensToSwappableTokens.get(tokenId) ?? []
    }
  }

  async getBestPath (fromTokenId: string, toTokenId: string): Promise<BestSwapPathResult> {
    const {
      fromToken,
      toToken,
      paths
    } = await this.getAllSwapPaths(fromTokenId, toTokenId)

    // always use direct path if available
    for (const path of paths) {
      const { estimatedReturn, estimatedReturnLessDexFees } = computeReturnLessDexFeesInDestinationToken(path, fromTokenId)
      if (path.length === 1) {
        return {
          fromToken: fromToken,
          toToken: toToken,
          bestPath: path,
          estimatedReturn: formatNumber(estimatedReturn), // denoted in toToken
          estimatedReturnLessDexFees: formatNumber(estimatedReturnLessDexFees) // denoted in toToken
        }
      }
    }

    // otherwise, search for the best path based on return
    let bestPath: SwapPathPoolPair[] = []
    let bestReturnLessDexFees = new BigNumber(0)
    let bestReturn = new BigNumber(0)

    for (const path of paths) {
      const { estimatedReturnLessDexFees, estimatedReturn } = computeReturnLessDexFeesInDestinationToken(path, fromTokenId)
      if (estimatedReturn.isGreaterThan(bestReturn)) {
        bestReturn = estimatedReturn
      }
      if (estimatedReturnLessDexFees.isGreaterThan(bestReturnLessDexFees)) {
        bestReturnLessDexFees = estimatedReturnLessDexFees
        bestPath = path
      }
    }

    return {
      fromToken: fromToken,
      toToken: toToken,
      bestPath: bestPath,
      estimatedReturnLessDexFees: formatNumber(bestReturnLessDexFees), // denoted in toToken
      estimatedReturn: formatNumber(bestReturn)
    }
  }

  /**
   * Get all poolPairs that can support a direct swap or composite swaps from one token to another.
   * @param {number} fromTokenId
   * @param {number} toTokenId
   */
  async getAllSwapPaths (fromTokenId: string, toTokenId: string): Promise<SwapPathsResult> {
    if (fromTokenId === toTokenId) {
      throw new Error('Invalid tokens: fromToken must be different from toToken')
    }

    await this.syncTokenGraphIfEmpty()

    const result: SwapPathsResult = {
      fromToken: await this.getTokenIdentifier(fromTokenId),
      toToken: await this.getTokenIdentifier(toTokenId),
      paths: []
    }

    if (
      !this.tokenGraph.hasNode(fromTokenId) ||
      !this.tokenGraph.hasNode(toTokenId)
    ) {
      return result
    }

    result.paths = await this.computePathsBetweenTokens(fromTokenId, toTokenId)
    return result
  }

  /**
   * Performs graph traversal to compute all possible paths between two tokens.
   * Must be able to handle cycles.
   * @param {number} fromTokenId
   * @param {number} toTokenId
   * @return {Promise<SwapPathPoolPair[][]>}
   * @private
   */
  private async computePathsBetweenTokens (
    fromTokenId: string,
    toTokenId: string
  ): Promise<SwapPathPoolPair[][]> {
    const poolPairPaths: SwapPathPoolPair[][] = []

    for (const path of allSimplePaths(this.tokenGraph, fromTokenId, toTokenId)) {
      // Max 3 hops, e.g. A --> B --> C --> D
      if (path.length > 4) {
        continue
      }

      const poolPairs: SwapPathPoolPair[] = []

      // Iterate over the path pairwise; ( tokenA )---< poolPairId >---( tokenB )
      // to collect poolPair info into the final result
      for (let i = 1; i < path.length; i++) {
        const tokenA = path[i - 1]
        const tokenB = path[i]

        const poolPairId = this.tokenGraph.edge(tokenA, tokenB)
        if (poolPairId === undefined) {
          throw new Error(
            'Unexpected error encountered during path finding - ' +
            `could not find edge between ${tokenA} and ${tokenB}`
          )
        }

        const poolPair = await this.getPoolPairInfo(poolPairId)
        const estimatedDexFeesInPct = await this.poolPairFeesServices.getDexFeesPct(poolPair, tokenA, tokenB)

        poolPairs.push({
          poolPairId: poolPairId,
          symbol: poolPair.symbol,
          tokenA: await this.getTokenIdentifier(poolPair.idTokenA),
          tokenB: await this.getTokenIdentifier(poolPair.idTokenB),
          priceRatio: {
            ab: formatNumber(new BigNumber(poolPair['reserveA/reserveB'])),
            ba: formatNumber(new BigNumber(poolPair['reserveB/reserveA']))
          },
          commissionFeeInPct: formatNumber(new BigNumber(poolPair.commission)),
          ...((estimatedDexFeesInPct != null) && { estimatedDexFeesInPct })
        })
      }

      poolPairPaths.push(poolPairs)
    }

    return poolPairPaths
  }

  /**
   * Derives from PoolPairToken to construct an undirected graph.
   * Each node represents a token, each edge represents a poolPair that bridges a pair of tokens.
   * For example, [[A-DFI], [B-DFI]] creates an undirected graph with 3 nodes and 2 edges:
   * ( A )--- A-DFI ---( DFI )--- B-DFI ---( B )
   * @param {PoolPairToken[]} poolPairList - poolPairTokens to derive tokens and poolPairs added to the graph
   * @private
   */
  private async addTokensAndConnectionsToGraph (poolPairList: PoolPairInfoWithId[]): Promise<void> {
    for (const poolPair of poolPairList) {
      if (await this.isPoolPairIgnored(poolPair)) {
        continue
      }

      if (!this.tokenGraph.hasNode(poolPair.idTokenA)) {
        this.tokenGraph.addNode(poolPair.idTokenA)
      }
      if (!this.tokenGraph.hasNode(poolPair.idTokenB)) {
        this.tokenGraph.addNode(poolPair.idTokenB)
      }
      if (!this.tokenGraph.hasEdge(poolPair.idTokenA, poolPair.idTokenB)) {
        this.tokenGraph.addUndirectedEdgeWithKey(poolPair.id, poolPair.idTokenA, poolPair.idTokenB)
      }
    }
  }

  private async isPoolPairIgnored (poolPair: PoolPairInfoWithId): Promise<boolean> {
    if (!poolPair.status) {
      return true
    }

    // Hot Fix for MainNet due to cost of running getPoolPairInfo during boot up.
    if (this.network === 'mainnet') {
      if (poolPair.id === '48') {
        return true
      }
    }

    return false
  }

  private async getTokenIdentifier (tokenId: string): Promise<TokenIdentifier> {
    const tokenInfo = await this.deFiDCache.getTokenInfo(tokenId)
    if (tokenInfo === undefined) {
      throw new NotFoundException(`Unable to find token ${tokenId}`)
    }
    return {
      id: tokenId,
      name: tokenInfo.name,
      symbol: tokenInfo.symbol,
      displaySymbol: parseDisplaySymbol(tokenInfo)
    }
  }

  private async getPoolPairInfo (poolPairId: string): Promise<PoolPairInfo> {
    const poolPair = await this.deFiDCache.getPoolPairInfoFromPoolPairs(poolPairId)
    if (poolPair === undefined) {
      throw new NotFoundException(`Unable to find token ${poolPairId}`)
    }
    return poolPair
  }

  /**
   * Indexes each token to their graph 'component', allowing quick queries for
   * all the swappable tokens for a given token.
   * @private
   */
  private async updateTokensToSwappableTokens (): Promise<void> {
    const components = connectedComponents(this.tokenGraph)
    for (const component of components) {
      // enrich with symbol
      const tokens: TokenIdentifier[] = []
      for (const tokenId of component) {
        tokens.push(await this.getTokenIdentifier(tokenId))
      }

      // index each token to their swappable tokens
      for (const token of tokens) {
        this.tokensToSwappableTokens.set(
          token.id,
          tokens.filter(tk => tk.id !== token.id) // exclude tokens from their own 'swappables' list
        )
      }
    }
  }

  private async syncTokenGraphIfEmpty (): Promise<void> {
    if (this.tokenGraph.size === 0) {
      await this.syncTokenGraph()
    }
  }
}

function computeReturnLessDexFeesInDestinationToken (path: SwapPathPoolPair[], fromTokenId: string): {
  estimatedReturn: BigNumber
  estimatedReturnLessDexFees: BigNumber
} {
  let estimatedReturnLessDexFees = new BigNumber(1)
  let estimatedReturn = new BigNumber(1)
  let priceRatio, fromTokenFeePct, toTokenFeePct

  for (const poolPair of path) {
    if (fromTokenId === poolPair.tokenA.id) {
      fromTokenId = poolPair.tokenB.id
      priceRatio = poolPair.priceRatio.ba
      fromTokenFeePct = poolPair.estimatedDexFeesInPct?.ba
      toTokenFeePct = poolPair.estimatedDexFeesInPct?.ab
    } else {
      fromTokenId = poolPair.tokenA.id
      priceRatio = poolPair.priceRatio.ab
      fromTokenFeePct = poolPair.estimatedDexFeesInPct?.ab
      toTokenFeePct = poolPair.estimatedDexFeesInPct?.ba
    }

    estimatedReturn = estimatedReturn.multipliedBy(priceRatio)

    // less commission fee
    const commissionFee = estimatedReturnLessDexFees.multipliedBy(poolPair.commissionFeeInPct)
    estimatedReturnLessDexFees = estimatedReturnLessDexFees.minus(commissionFee)

    // less dex fee fromToken
    const fromTokenEstimatedDexFee = new BigNumber(fromTokenFeePct ?? 0).multipliedBy(estimatedReturnLessDexFees)
    estimatedReturnLessDexFees = estimatedReturnLessDexFees.minus(fromTokenEstimatedDexFee)

    // convert to toToken
    const fromTokenEstimatedReturnLessDexFee = estimatedReturnLessDexFees.multipliedBy(priceRatio)
    const toTokenEstimatedDexFee = new BigNumber(toTokenFeePct ?? 0).multipliedBy(fromTokenEstimatedReturnLessDexFee)

    // less dex fee toToken
    estimatedReturnLessDexFees = fromTokenEstimatedReturnLessDexFee.minus(toTokenEstimatedDexFee)
  }

  return {
    estimatedReturn: estimatedReturn.decimalPlaces(8, BigNumber.ROUND_CEIL),
    estimatedReturnLessDexFees: estimatedReturnLessDexFees.decimalPlaces(8, BigNumber.ROUND_CEIL)
  }
}

function formatNumber (number: BigNumber): string {
  return number.eq(0)
    ? '0'
    : number.toFixed(8)
}