decred.org/dcrdex@v1.0.3/client/webserver/site/src/js/markets.ts (about)

     1  import Doc, { WalletIcons, parseFloatDefault } from './doc'
     2  import State from './state'
     3  import BasePage from './basepage'
     4  import OrderBook from './orderbook'
     5  import { ReputationMeter, tradingLimits, strongTier } from './account'
     6  import {
     7    CandleChart,
     8    DepthChart,
     9    DepthLine,
    10    CandleReporters,
    11    MouseReport,
    12    VolumeReport,
    13    DepthMarker,
    14    Wave
    15  } from './charts'
    16  import { postJSON } from './http'
    17  import {
    18    NewWalletForm,
    19    AccelerateOrderForm,
    20    DepositAddress,
    21    TokenApprovalForm,
    22    bind as bindForm,
    23    Forms
    24  } from './forms'
    25  import * as OrderUtil from './orderutil'
    26  import ws from './ws'
    27  import * as intl from './locales'
    28  import {
    29    app,
    30    SupportedAsset,
    31    PageElement,
    32    Order,
    33    Market,
    34    OrderEstimate,
    35    MaxOrderEstimate,
    36    Exchange,
    37    UnitInfo,
    38    Asset,
    39    Candle,
    40    CandlesPayload,
    41    TradeForm,
    42    BookUpdate,
    43    MaxSell,
    44    MaxBuy,
    45    SwapEstimate,
    46    MarketOrderBook,
    47    APIResponse,
    48    PreSwap,
    49    PreRedeem,
    50    WalletStateNote,
    51    WalletSyncNote,
    52    WalletCreationNote,
    53    SpotPriceNote,
    54    BondNote,
    55    OrderNote,
    56    EpochNote,
    57    BalanceNote,
    58    MiniOrder,
    59    RemainderUpdate,
    60    ConnEventNote,
    61    OrderOption,
    62    ConnectionStatus,
    63    RecentMatch,
    64    MatchNote,
    65    ApprovalStatus,
    66    OrderFilter,
    67    RunStatsNote,
    68    RunEventNote,
    69    EpochReportNote,
    70    CEXProblemsNote
    71  } from './registry'
    72  import { setOptionTemplates } from './opts'
    73  import { RunningMarketMakerDisplay, RunningMMDisplayElements } from './mmutil'
    74  
    75  const bind = Doc.bind
    76  
    77  const bookRoute = 'book'
    78  const bookOrderRoute = 'book_order'
    79  const unbookOrderRoute = 'unbook_order'
    80  const updateRemainingRoute = 'update_remaining'
    81  const epochOrderRoute = 'epoch_order'
    82  const candlesRoute = 'candles'
    83  const candleUpdateRoute = 'candle_update'
    84  const unmarketRoute = 'unmarket'
    85  const epochMatchSummaryRoute = 'epoch_match_summary'
    86  
    87  const anHour = 60 * 60 * 1000 // milliseconds
    88  const maxUserOrdersShown = 10
    89  
    90  const buyBtnClass = 'buygreen-bg'
    91  const sellBtnClass = 'sellred-bg'
    92  
    93  const fiveMinBinKey = '5m'
    94  const oneHrBinKey = '1h'
    95  
    96  const percentFormatter = new Intl.NumberFormat(Doc.languages(), {
    97    minimumFractionDigits: 1,
    98    maximumFractionDigits: 2
    99  })
   100  
   101  const parentIDNone = 0xFFFFFFFF
   102  
   103  interface MetaOrder {
   104    div: HTMLElement
   105    header: Record<string, PageElement>
   106    details: Record<string, PageElement>
   107    ord: Order
   108    cancelling?: boolean
   109  }
   110  
   111  interface CancelData {
   112    bttn: PageElement
   113    order: Order
   114  }
   115  
   116  interface CurrentMarket {
   117    dex: Exchange
   118    sid: string // A string market identifier used by the DEX.
   119    cfg: Market
   120    base: SupportedAsset
   121    quote: SupportedAsset
   122    baseUnitInfo: UnitInfo
   123    quoteUnitInfo: UnitInfo
   124    maxSellRequested: boolean
   125    maxSell: MaxOrderEstimate | null
   126    sellBalance: number
   127    buyBalance: number
   128    maxBuys: Record<number, MaxOrderEstimate>
   129    candleCaches: Record<string, CandlesPayload>
   130    baseCfg: Asset
   131    quoteCfg: Asset
   132    rateConversionFactor: number
   133    bookLoaded: boolean
   134  }
   135  
   136  interface LoadTracker {
   137    loaded: () => void
   138    timer: number
   139  }
   140  
   141  interface OrderRow extends HTMLElement {
   142    manager: OrderTableRowManager
   143  }
   144  
   145  interface StatsDisplay {
   146    row: PageElement
   147    tmpl: Record<string, PageElement>
   148  }
   149  
   150  interface MarketsPageParams {
   151    host: string
   152    baseID: string
   153    quoteID: string
   154  }
   155  
   156  export default class MarketsPage extends BasePage {
   157    page: Record<string, PageElement>
   158    main: HTMLElement
   159    maxLoaded: (() => void) | null
   160    maxOrderUpdateCounter: number
   161    market: CurrentMarket
   162    openAsset: SupportedAsset
   163    currentCreate: SupportedAsset
   164    maxEstimateTimer: number | null
   165    book: OrderBook
   166    cancelData: CancelData
   167    metaOrders: Record<string, MetaOrder>
   168    preorderCache: Record<string, OrderEstimate>
   169    currentOrder: TradeForm
   170    depthLines: Record<string, DepthLine[]>
   171    activeMarkerRate: number | null
   172    hovers: HTMLElement[]
   173    ogTitle: string
   174    depthChart: DepthChart
   175    candleChart: CandleChart
   176    candleDur: string
   177    balanceWgt: BalanceWidget
   178    mm: RunningMarketMakerDisplay
   179    marketList: MarketList
   180    newWalletForm: NewWalletForm
   181    depositAddrForm: DepositAddress
   182    approveTokenForm: TokenApprovalForm
   183    reputationMeter: ReputationMeter
   184    keyup: (e: KeyboardEvent) => void
   185    secondTicker: number
   186    candlesLoading: LoadTracker | null
   187    accelerateOrderForm: AccelerateOrderForm
   188    recentMatches: RecentMatch[]
   189    recentMatchesSortKey: string
   190    recentMatchesSortDirection: 1 | -1
   191    stats: [StatsDisplay, StatsDisplay]
   192    loadingAnimations: { candles?: Wave, depth?: Wave }
   193    mmRunning: boolean | undefined
   194    forms: Forms
   195    constructor (main: HTMLElement, pageParams: MarketsPageParams) {
   196      super()
   197  
   198      const page = this.page = Doc.idDescendants(main)
   199      this.main = main
   200      if (!this.main.parentElement) return // Not gonna happen, but TypeScript cares.
   201      // There may be multiple pending updates to the max order. This makes sure
   202      // that the screen is updated with the most recent one.
   203      this.maxOrderUpdateCounter = 0
   204      this.metaOrders = {}
   205      this.recentMatches = []
   206      this.preorderCache = {}
   207      this.depthLines = {
   208        hover: [],
   209        input: []
   210      }
   211      this.hovers = []
   212      // 'Recent Matches' list sort key and direction.
   213      this.recentMatchesSortKey = 'age'
   214      this.recentMatchesSortDirection = -1
   215      // store original title so we can re-append it when updating market value.
   216      this.ogTitle = document.title
   217      this.forms = new Forms(page.forms, {
   218        closed: (closedForm: PageElement | undefined) => {
   219          if (closedForm === page.vDetailPane) {
   220            this.showVerifyForm()
   221          }
   222        }
   223      })
   224  
   225      const depthReporters = {
   226        click: (x: number) => { this.reportDepthClick(x) },
   227        volume: (r: VolumeReport) => { this.reportDepthVolume(r) },
   228        mouse: (r: MouseReport) => { this.reportDepthMouse(r) },
   229        zoom: (z: number) => { this.reportDepthZoom(z) }
   230      }
   231      this.depthChart = new DepthChart(page.depthChart, depthReporters, State.fetchLocal(State.depthZoomLK))
   232  
   233      const candleReporters: CandleReporters = {
   234        mouse: c => { this.reportMouseCandle(c) }
   235      }
   236      this.candleChart = new CandleChart(page.candlesChart, candleReporters)
   237  
   238      const success = () => { /* do nothing */ }
   239      // Do not call cleanTemplates before creating the AccelerateOrderForm
   240      this.accelerateOrderForm = new AccelerateOrderForm(page.accelerateForm, success)
   241  
   242      this.approveTokenForm = new TokenApprovalForm(page.approveTokenForm)
   243  
   244      // Set user's last known candle duration.
   245      this.candleDur = State.fetchLocal(State.lastCandleDurationLK) || oneHrBinKey
   246  
   247      // Setup the register to trade button.
   248      // TODO: Use dexsettings page?
   249      const registerBttn = Doc.tmplElement(page.notRegistered, 'registerBttn')
   250      bind(registerBttn, 'click', () => {
   251        app().loadPage('register', { host: this.market.dex.host })
   252      })
   253  
   254      // Set up the BalanceWidget.
   255      {
   256        page.walletInfoTmpl.removeAttribute('id')
   257        const bWidget = page.walletInfoTmpl
   258        const qWidget = page.walletInfoTmpl.cloneNode(true) as PageElement
   259        bWidget.after(qWidget)
   260        const wgt = this.balanceWgt = new BalanceWidget(bWidget, qWidget)
   261        const baseIcons = wgt.base.stateIcons.icons
   262        const quoteIcons = wgt.quote.stateIcons.icons
   263        bind(wgt.base.tmpl.connect, 'click', () => { this.unlockWallet(this.market.base.id) })
   264        bind(wgt.quote.tmpl.connect, 'click', () => { this.unlockWallet(this.market.quote.id) })
   265        bind(wgt.base.tmpl.expired, 'click', () => { this.unlockWallet(this.market.base.id) })
   266        bind(wgt.quote.tmpl.expired, 'click', () => { this.unlockWallet(this.market.quote.id) })
   267        bind(baseIcons.sleeping, 'click', () => { this.unlockWallet(this.market.base.id) })
   268        bind(quoteIcons.sleeping, 'click', () => { this.unlockWallet(this.market.quote.id) })
   269        bind(baseIcons.locked, 'click', () => { this.unlockWallet(this.market.base.id) })
   270        bind(quoteIcons.locked, 'click', () => { this.unlockWallet(this.market.quote.id) })
   271        bind(baseIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.base) })
   272        bind(quoteIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.quote) })
   273        bind(wgt.base.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.base) })
   274        bind(wgt.quote.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.quote) })
   275        bind(wgt.base.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.base.id) })
   276        bind(wgt.quote.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.quote.id) })
   277        bind(wgt.base.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.base.id) })
   278        bind(wgt.quote.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.quote.id) })
   279        this.depositAddrForm = new DepositAddress(page.deposit)
   280      }
   281  
   282      const runningMMDisplayElements: RunningMMDisplayElements = {
   283        orderReportForm: page.orderReportForm,
   284        dexBalancesRowTmpl: page.dexBalancesRowTmpl,
   285        placementRowTmpl: page.placementRowTmpl,
   286        placementAmtRowTmpl: page.placementAmtRowTmpl
   287      }
   288      Doc.cleanTemplates(page.dexBalancesRowTmpl, page.placementRowTmpl, page.placementAmtRowTmpl)
   289      this.mm = new RunningMarketMakerDisplay(page.mmRunning, this.forms, runningMMDisplayElements, 'markets')
   290  
   291      this.reputationMeter = new ReputationMeter(page.reputationMeter)
   292  
   293      // Bind toggle wallet status form.
   294      bindForm(page.toggleWalletStatusConfirm, page.toggleWalletStatusSubmit, async () => { this.toggleWalletStatus() })
   295  
   296      // Prepare templates for the buy and sell tables and the user's order table.
   297      setOptionTemplates(page)
   298  
   299      Doc.cleanTemplates(
   300        page.orderRowTmpl, page.durBttnTemplate, page.booleanOptTmpl, page.rangeOptTmpl,
   301        page.orderOptTmpl, page.userOrderTmpl, page.recentMatchesTemplate
   302      )
   303  
   304      // Buttons to show token approval form
   305      bind(page.approveBaseBttn, 'click', () => { this.showTokenApprovalForm(true) })
   306      bind(page.approveQuoteBttn, 'click', () => { this.showTokenApprovalForm(false) })
   307  
   308      const toggleTradingTier = (show: boolean) => {
   309        Doc.setVis(!show, page.showTradingTier)
   310        Doc.setVis(show, page.tradingLimits, page.hideTradingTier)
   311      }
   312      bind(page.showTradingTier, 'click', () => { toggleTradingTier(true) })
   313      bind(page.hideTradingTier, 'click', () => { toggleTradingTier(false) })
   314  
   315      const toggleTradingReputation = (show: boolean) => {
   316        Doc.setVis(!show, page.showTradingReputation)
   317        Doc.setVis(show, page.reputationMeter, page.hideTradingReputation)
   318      }
   319      bind(page.showTradingReputation, 'click', () => { toggleTradingReputation(true) })
   320      bind(page.hideTradingReputation, 'click', () => { toggleTradingReputation(false) })
   321  
   322      // Buttons to set order type and side.
   323      bind(page.buyBttn, 'click', () => { this.setBuy() })
   324      bind(page.sellBttn, 'click', () => { this.setSell() })
   325  
   326      bind(page.limitBttn, 'click', () => {
   327        swapBttns(page.marketBttn, page.limitBttn)
   328        this.setOrderVisibility()
   329        if (!page.rateField.value) return
   330        this.depthLines.input = [{
   331          rate: parseFloatDefault(page.rateField.value, 0),
   332          color: this.isSell() ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine
   333        }]
   334        this.drawChartLines()
   335      })
   336      bind(page.marketBttn, 'click', () => {
   337        swapBttns(page.limitBttn, page.marketBttn)
   338        this.setOrderVisibility()
   339        this.setMarketBuyOrderEstimate()
   340        this.depthLines.input = []
   341        this.drawChartLines()
   342      })
   343      bind(page.maxOrd, 'click', () => {
   344        if (this.isSell()) {
   345          const maxSell = this.market.maxSell
   346          if (!maxSell) return
   347          page.lotField.value = String(maxSell.swap.lots)
   348        } else {
   349          const maxBuy = this.market.maxBuys[this.adjustedRate()]
   350          if (!maxBuy) return
   351          page.lotField.value = String(maxBuy.swap.lots)
   352        }
   353        this.lotChanged()
   354      })
   355  
   356      Doc.disableMouseWheel(page.rateField, page.lotField, page.qtyField, page.mktBuyField)
   357  
   358      // Handle the full orderbook sent on the 'book' route.
   359      ws.registerRoute(bookRoute, (data: BookUpdate) => { this.handleBookRoute(data) })
   360      // Handle the new order for the order book on the 'book_order' route.
   361      ws.registerRoute(bookOrderRoute, (data: BookUpdate) => { this.handleBookOrderRoute(data) })
   362      // Remove the order sent on the 'unbook_order' route from the orderbook.
   363      ws.registerRoute(unbookOrderRoute, (data: BookUpdate) => { this.handleUnbookOrderRoute(data) })
   364      // Update the remaining quantity on a booked order.
   365      ws.registerRoute(updateRemainingRoute, (data: BookUpdate) => { this.handleUpdateRemainingRoute(data) })
   366      // Handle the new order for the order book on the 'epoch_order' route.
   367      ws.registerRoute(epochOrderRoute, (data: BookUpdate) => { this.handleEpochOrderRoute(data) })
   368      // Handle the initial candlestick data on the 'candles' route.
   369      ws.registerRoute(candlesRoute, (data: BookUpdate) => { this.handleCandlesRoute(data) })
   370      // Handle the candles update on the 'candles' route.
   371      ws.registerRoute(candleUpdateRoute, (data: BookUpdate) => { this.handleCandleUpdateRoute(data) })
   372  
   373      // Handle the recent matches update on the 'epoch_report' route.
   374      ws.registerRoute(epochMatchSummaryRoute, (data: BookUpdate) => { this.handleEpochMatchSummary(data) })
   375      // Create a wallet
   376      this.newWalletForm = new NewWalletForm(page.newWalletForm, async () => { this.createWallet() })
   377      // Main order form.
   378      bindForm(page.orderForm, page.submitBttn, async () => { this.stepSubmit() })
   379      // Order verification form.
   380      bindForm(page.verifyForm, page.vSubmit, async () => { this.submitOrder() })
   381      // Cancel order form.
   382      bindForm(page.cancelForm, page.cancelSubmit, async () => { this.submitCancel() })
   383      // Order detail view.
   384      Doc.bind(page.vFeeDetails, 'click', () => this.forms.show(page.vDetailPane))
   385      Doc.bind(page.closeDetailPane, 'click', () => this.showVerifyForm())
   386      // // Bind active orders list's header sort events.
   387      page.recentMatchesTable.querySelectorAll('[data-ordercol]')
   388        .forEach((th: HTMLElement) => bind(
   389          th, 'click', () => setRecentMatchesSortCol(th.dataset.ordercol || '')
   390        ))
   391  
   392      const setRecentMatchesSortCol = (key: string) => {
   393        // First unset header's current sorted col classes.
   394        unsetRecentMatchesSortColClasses()
   395        if (this.recentMatchesSortKey === key) {
   396          this.recentMatchesSortDirection *= -1
   397        } else {
   398          this.recentMatchesSortKey = key
   399          this.recentMatchesSortDirection = 1
   400        }
   401        this.refreshRecentMatchesTable()
   402        setRecentMatchesSortColClasses()
   403      }
   404  
   405      // sortClassByDirection receives a sort direction and return a class based on it.
   406      const sortClassByDirection = (element: 1 | -1) => {
   407        if (element === 1) return 'sorted-asc'
   408        return 'sorted-dsc'
   409      }
   410  
   411      const unsetRecentMatchesSortColClasses = () => {
   412        page.recentMatchesTable.querySelectorAll('[data-ordercol]')
   413          .forEach(th => th.classList.remove('sorted-asc', 'sorted-dsc'))
   414      }
   415  
   416      const setRecentMatchesSortColClasses = () => {
   417        const key = this.recentMatchesSortKey
   418        const sortCls = sortClassByDirection(this.recentMatchesSortDirection)
   419        Doc.safeSelector(page.recentMatchesTable, `[data-ordercol=${key}]`).classList.add(sortCls)
   420      }
   421  
   422      // Set default's sorted col header classes.
   423      setRecentMatchesSortColClasses()
   424  
   425      const closePopups = () => {
   426        this.forms.close()
   427      }
   428  
   429      this.keyup = (e: KeyboardEvent) => {
   430        if (e.key === 'Escape') {
   431          closePopups()
   432        }
   433      }
   434      bind(document, 'keyup', this.keyup)
   435  
   436      page.forms.querySelectorAll('.form-closer').forEach(el => {
   437        Doc.bind(el, 'click', () => { closePopups() })
   438      })
   439  
   440      // Event listeners for interactions with the various input fields.
   441      bind(page.lotField, ['change', 'keyup'], () => { this.lotChanged() })
   442      bind(page.qtyField, 'change', () => { this.quantityChanged(true) })
   443      bind(page.qtyField, 'keyup', () => { this.quantityChanged(false) })
   444      bind(page.mktBuyField, ['change', 'keyup'], () => { this.marketBuyChanged() })
   445      bind(page.rateField, 'change', () => { this.rateFieldChanged() })
   446      bind(page.rateField, 'keyup', () => { this.previewQuoteAmt(true) })
   447  
   448      // Market search input bindings.
   449      bind(page.marketSearchV1, ['change', 'keyup'], () => { this.filterMarkets() })
   450  
   451      // Acknowledge the order disclaimer.
   452      const setDisclaimerAckViz = (acked: boolean) => {
   453        Doc.setVis(!acked, page.disclaimer, page.disclaimerAck)
   454        Doc.setVis(acked, page.showDisclaimer)
   455      }
   456      bind(page.disclaimerAck, 'click', () => {
   457        State.storeLocal(State.orderDisclaimerAckedLK, true)
   458        setDisclaimerAckViz(true)
   459      })
   460      bind(page.showDisclaimer, 'click', () => {
   461        State.storeLocal(State.orderDisclaimerAckedLK, false)
   462        setDisclaimerAckViz(false)
   463      })
   464      setDisclaimerAckViz(State.fetchLocal(State.orderDisclaimerAckedLK))
   465  
   466      const clearChartLines = () => {
   467        this.depthLines.hover = []
   468        this.drawChartLines()
   469      }
   470      bind(page.buyRows, 'mouseleave', clearChartLines)
   471      bind(page.sellRows, 'mouseleave', clearChartLines)
   472      bind(page.userOrders, 'mouseleave', () => {
   473        this.activeMarkerRate = null
   474        this.setDepthMarkers()
   475      })
   476  
   477      const stats0 = page.marketStats
   478      const stats1 = stats0.cloneNode(true) as PageElement
   479      stats1.classList.add('listopen')
   480      Doc.hide(stats0, stats1)
   481      stats1.removeAttribute('id')
   482      app().headerSpace.appendChild(stats1)
   483      this.stats = [{ row: stats0, tmpl: Doc.parseTemplate(stats0) }, { row: stats1, tmpl: Doc.parseTemplate(stats1) }]
   484  
   485      const closeMarketsList = () => {
   486        State.storeLocal(State.leftMarketDockLK, '0')
   487        page.leftMarketDock.classList.remove('default')
   488        page.leftMarketDock.classList.add('stashed')
   489        for (const s of this.stats) s.row.classList.remove('listopen')
   490      }
   491      const openMarketsList = () => {
   492        State.storeLocal(State.leftMarketDockLK, '1')
   493        page.leftMarketDock.classList.remove('default', 'stashed')
   494        for (const s of this.stats) s.row.classList.add('listopen')
   495      }
   496      Doc.bind(page.leftHider, 'click', () => closeMarketsList())
   497      Doc.bind(page.marketReopener, 'click', () => openMarketsList())
   498      for (const s of this.stats) {
   499        Doc.bind(s.tmpl.marketSelect, 'click', () => {
   500          if (page.leftMarketDock.clientWidth === 0) openMarketsList()
   501          else closeMarketsList()
   502        })
   503      }
   504      this.marketList = new MarketList(page.marketListV1)
   505      // Prepare the list of markets.
   506      for (const row of this.marketList.markets) {
   507        bind(row.node, 'click', () => {
   508          // return early if the market is already set
   509          const { quoteid: quoteID, baseid: baseID, xc: { host } } = row.mkt
   510          if (this.market?.base?.id === baseID && this.market?.quote?.id === quoteID) return
   511          this.startLoadingAnimations()
   512          this.setMarket(host, baseID, quoteID)
   513        })
   514      }
   515      if (State.fetchLocal(State.leftMarketDockLK) !== '1') { // It is shown by default, hiding if necessary.
   516        closeMarketsList()
   517      }
   518  
   519      // Notification filters.
   520      app().registerNoteFeeder({
   521        order: (note: OrderNote) => { this.handleOrderNote(note) },
   522        match: (note: MatchNote) => { this.handleMatchNote(note) },
   523        epoch: (note: EpochNote) => { this.handleEpochNote(note) },
   524        conn: (note: ConnEventNote) => { this.handleConnNote(note) },
   525        balance: (note: BalanceNote) => { this.handleBalanceNote(note) },
   526        bondpost: (note: BondNote) => { this.handleBondUpdate(note) },
   527        spots: (note: SpotPriceNote) => { this.handlePriceUpdate(note) },
   528        walletstate: (note: WalletStateNote) => { this.handleWalletState(note) },
   529        reputation: () => { this.updateReputation() },
   530        feepayment: () => { this.updateReputation() },
   531        runstats: (note: RunStatsNote) => {
   532          if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return
   533          this.mm.update()
   534          if (Boolean(this.mmRunning) !== Boolean(note.stats)) {
   535            this.mmRunning = Boolean(note.stats)
   536            this.resolveOrderFormVisibility()
   537          }
   538        },
   539        epochreport: (note: EpochReportNote) => {
   540          if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return
   541          this.mm.handleEpochReportNote(note)
   542        },
   543        cexproblems: (note: CEXProblemsNote) => {
   544          if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return
   545          this.mm.handleCexProblemsNote(note)
   546        },
   547        runevent: (note: RunEventNote) => {
   548          if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return
   549          this.mm.update()
   550        }
   551      })
   552  
   553      this.loadingAnimations = {}
   554      this.startLoadingAnimations()
   555  
   556      // Start a ticker to update time-since values.
   557      this.secondTicker = window.setInterval(() => {
   558        for (const mord of Object.values(this.metaOrders)) {
   559          mord.details.age.textContent = Doc.timeSince(mord.ord.submitTime)
   560        }
   561        for (const td of Doc.applySelector(page.recentMatchesLiveList, '[data-tmpl=age]')) {
   562          td.textContent = Doc.timeSince(parseFloat(td.dataset.sinceStamp ?? '0'))
   563        }
   564      }, 1000)
   565  
   566      this.init(pageParams)
   567    }
   568  
   569    async init (pageParams?: MarketsPageParams) {
   570      // Fetch the first market in the list, or the users last selected market, if
   571      // it exists.
   572      let selected
   573      if (pageParams?.host) {
   574        selected = makeMarket(pageParams.host, parseInt(pageParams.baseID), parseInt(pageParams.quoteID))
   575      } else {
   576        selected = State.fetchLocal(State.lastMarketLK)
   577      }
   578      if (!selected || !this.marketList.exists(selected.host, selected.base, selected.quote)) {
   579        const first = this.marketList.first()
   580        if (first) selected = { host: first.mkt.xc.host, base: first.mkt.baseid, quote: first.mkt.quoteid }
   581      }
   582      if (selected) this.setMarket(selected.host, selected.base, selected.quote)
   583      else this.balanceWgt.setBalanceVisibility(false) // no market to display balance widget for.
   584  
   585      // set the initial state for the registration status
   586      this.setRegistrationStatusVisibility()
   587    }
   588  
   589    startLoadingAnimations () {
   590      const { page, loadingAnimations: anis, depthChart, candleChart } = this
   591      depthChart.canvas.classList.add('invisible')
   592      candleChart.canvas.classList.add('invisible')
   593      if (anis.candles) anis.candles.stop()
   594      anis.candles = new Wave(page.candlesChart, { message: intl.prep(intl.ID_CANDLES_LOADING) })
   595      if (anis.depth) anis.depth.stop()
   596      anis.depth = new Wave(page.depthChart, { message: intl.prep(intl.ID_DEPTH_LOADING) })
   597    }
   598  
   599    /* isSell is true if the user has selected sell in the order options. */
   600    isSell () {
   601      return this.page.sellBttn.classList.contains('selected')
   602    }
   603  
   604    /* isLimit is true if the user has selected the "limit order" tab. */
   605    isLimit () {
   606      return this.page.limitBttn.classList.contains('selected')
   607    }
   608  
   609    setBuy () {
   610      const { page } = this
   611      swapBttns(page.sellBttn, page.buyBttn)
   612      page.submitBttn.classList.remove(sellBtnClass)
   613      page.submitBttn.classList.add(buyBtnClass)
   614      page.maxLbl.textContent = intl.prep(intl.ID_BUY)
   615      this.setOrderBttnText()
   616      this.setOrderVisibility()
   617      this.drawChartLines()
   618      if (!this.isLimit()) {
   619        this.marketBuyChanged()
   620      } else {
   621        this.currentOrder = this.parseOrder()
   622        this.updateOrderBttnState()
   623      }
   624    }
   625  
   626    setSell () {
   627      const { page } = this
   628      swapBttns(page.buyBttn, page.sellBttn)
   629      page.submitBttn.classList.add(sellBtnClass)
   630      page.submitBttn.classList.remove(buyBtnClass)
   631      page.maxLbl.textContent = intl.prep(intl.ID_SELL)
   632      this.setOrderBttnText()
   633      this.setOrderVisibility()
   634      this.drawChartLines()
   635      this.currentOrder = this.parseOrder()
   636      this.updateOrderBttnState()
   637    }
   638  
   639    /* hasPendingBonds is true if there are pending bonds */
   640    hasPendingBonds (): boolean {
   641      return Object.keys(this.market.dex.auth.pendingBonds || []).length > 0
   642    }
   643  
   644    /* setCurrMarketPrice updates the current market price on the stats displays
   645       and the orderbook display. */
   646    setCurrMarketPrice (): void {
   647      const selected = this.market
   648      if (!selected) return
   649      // Get an up-to-date Market.
   650      const xc = app().exchanges[selected.dex.host]
   651      const mkt = xc.markets[selected.cfg.name]
   652      if (!mkt.spot) return
   653  
   654      for (const s of this.stats) {
   655        const { unitInfo: { conventional: { conversionFactor: cFactor, unit } } } = xc.assets[mkt.baseid]
   656        const fiatRate = app().fiatRatesMap[mkt.baseid]
   657        if (fiatRate) {
   658          s.tmpl.volume.textContent = Doc.formatFourSigFigs(mkt.spot.vol24 / cFactor * fiatRate)
   659          s.tmpl.volUnit.textContent = 'USD'
   660        } else {
   661          s.tmpl.volume.textContent = Doc.formatFourSigFigs(mkt.spot.vol24 / cFactor)
   662          s.tmpl.volUnit.textContent = unit
   663        }
   664        setPriceAndChange(s.tmpl, xc, mkt)
   665      }
   666  
   667      this.page.obPrice.textContent = Doc.formatFourSigFigs(mkt.spot.rate / this.market.rateConversionFactor)
   668      this.page.obPrice.classList.remove('sellcolor', 'buycolor')
   669      this.page.obPrice.classList.add(mkt.spot.change24 >= 0 ? 'buycolor' : 'sellcolor')
   670      Doc.setVis(mkt.spot.change24 >= 0, this.page.obUp)
   671      Doc.setVis(mkt.spot.change24 < 0, this.page.obDown)
   672    }
   673  
   674    /* setMarketDetails updates the currency names on the stats displays. */
   675    setMarketDetails () {
   676      if (!this.market) return
   677      for (const s of this.stats) {
   678        const { baseCfg: ba, quoteCfg: qa } = this.market
   679        s.tmpl.baseIcon.src = Doc.logoPath(ba.symbol)
   680        s.tmpl.quoteIcon.src = Doc.logoPath(qa.symbol)
   681        Doc.empty(s.tmpl.baseSymbol, s.tmpl.quoteSymbol)
   682        s.tmpl.baseSymbol.appendChild(Doc.symbolize(ba, true))
   683        s.tmpl.quoteSymbol.appendChild(Doc.symbolize(qa, true))
   684      }
   685    }
   686  
   687    /* setHighLow calculates the high and low rates over the last 24 hours. */
   688    setHighLow () {
   689      let [high, low] = [0, 0]
   690      const spot = this.market.cfg.spot
   691      // Use spot values for 24 hours high and low rates if it is available. We
   692      // will default to setting it from candles if it's not.
   693      if (spot && spot.low24 && spot.high24) {
   694        high = spot.high24
   695        low = spot.low24
   696      } else {
   697        const cache = this.market?.candleCaches[fiveMinBinKey]
   698        if (!cache) {
   699          if (this.candleDur !== fiveMinBinKey) {
   700            this.requestCandles(fiveMinBinKey)
   701            return
   702          }
   703          for (const s of this.stats) {
   704            s.tmpl.high.textContent = '-'
   705            s.tmpl.low.textContent = '-'
   706          }
   707          return
   708        }
   709  
   710        // Set high and low rates from candles.
   711        const aDayAgo = new Date().getTime() - 86400000
   712        for (let i = cache.candles.length - 1; i >= 0; i--) {
   713          const c = cache.candles[i]
   714          if (c.endStamp < aDayAgo) break
   715          if (low === 0 || (c.lowRate > 0 && c.lowRate < low)) low = c.lowRate
   716          if (c.highRate > high) high = c.highRate
   717        }
   718      }
   719  
   720      const baseID = this.market.base.id
   721      const quoteID = this.market.quote.id
   722      const dex = this.market.dex
   723      for (const s of this.stats) {
   724        s.tmpl.high.textContent = high > 0 ? Doc.formatFourSigFigs(app().conventionalRate(baseID, quoteID, high, dex)) : '-'
   725        s.tmpl.low.textContent = low > 0 ? Doc.formatFourSigFigs(app().conventionalRate(baseID, quoteID, low, dex)) : '-'
   726      }
   727    }
   728  
   729    /* assetsAreSupported is true if all the assets of the current market are
   730     * supported
   731     */
   732    assetsAreSupported (): {
   733      isSupported: boolean;
   734      text: string;
   735      } {
   736      const { market: { base, quote, baseCfg, quoteCfg } } = this
   737      if (!base || !quote) {
   738        const symbol = base ? quoteCfg.symbol : baseCfg.symbol
   739        return {
   740          isSupported: false,
   741          text: intl.prep(intl.ID_NOT_SUPPORTED, { asset: symbol.toUpperCase() })
   742        }
   743      }
   744      // check if versions are supported. If asset is a token, we check if its
   745      // parent supports the version.
   746      const bVers = (base.token ? app().assets[base.token.parentID].info?.versions : base.info?.versions) as number[]
   747      const qVers = (quote.token ? app().assets[quote.token.parentID].info?.versions : quote.info?.versions) as number[]
   748      // if none them are token, just check if own asset is supported.
   749      let text = ''
   750      if (!bVers.includes(baseCfg.version)) {
   751        text = intl.prep(intl.ID_VERSION_NOT_SUPPORTED, { asset: base.symbol.toUpperCase(), version: baseCfg.version + '' })
   752      } else if (!qVers.includes(quoteCfg.version)) {
   753        text = intl.prep(intl.ID_VERSION_NOT_SUPPORTED, { asset: quote.symbol.toUpperCase(), version: quoteCfg.version + '' })
   754      }
   755      return {
   756        isSupported: bVers.includes(baseCfg.version) && qVers.includes(quoteCfg.version),
   757        text
   758      }
   759    }
   760  
   761    /*
   762     * setOrderVisibility sets which form is visible based on the specified
   763     * options.
   764     */
   765    setOrderVisibility () {
   766      const page = this.page
   767      if (this.isLimit()) {
   768        Doc.show(page.priceBox, page.tifBox, page.qtyBox, page.maxBox)
   769        Doc.hide(page.mktBuyBox)
   770        this.previewQuoteAmt(true)
   771      } else {
   772        Doc.hide(page.tifBox, page.maxBox, page.priceBox)
   773        if (this.isSell()) {
   774          Doc.hide(page.mktBuyBox)
   775          Doc.show(page.qtyBox)
   776          this.previewQuoteAmt(true)
   777        } else {
   778          Doc.show(page.mktBuyBox)
   779          Doc.hide(page.qtyBox)
   780          this.previewQuoteAmt(false)
   781        }
   782      }
   783      this.updateOrderBttnState()
   784    }
   785  
   786    /* resolveOrderFormVisibility displays or hides the 'orderForm' based on
   787     * a set of conditions to be met.
   788     */
   789    async resolveOrderFormVisibility () {
   790      const page = this.page
   791  
   792      const showOrderForm = async () : Promise<boolean> => {
   793        if (!this.assetsAreSupported().isSupported) return false // assets not supported
   794  
   795        if (!this.market || this.market.dex.auth.effectiveTier < 1) return false// acct suspended or not registered
   796  
   797        const { baseAssetApprovalStatus, quoteAssetApprovalStatus } = this.tokenAssetApprovalStatuses()
   798        if (baseAssetApprovalStatus !== ApprovalStatus.Approved || quoteAssetApprovalStatus !== ApprovalStatus.Approved) return false
   799  
   800        const { base, quote } = this.market
   801        const hasWallets = base && app().assets[base.id].wallet && quote && app().assets[quote.id].wallet
   802        if (!hasWallets) return false
   803        if (this.mmRunning) return false
   804        return true
   805      }
   806  
   807      Doc.setVis(await showOrderForm(), page.orderForm, page.orderTypeBttns)
   808  
   809      if (this.market) {
   810        const { auth: { effectiveTier, pendingStrength } } = this.market.dex
   811        Doc.setVis(effectiveTier > 0 || pendingStrength > 0, page.reputationAndTradingTierBox)
   812      }
   813  
   814      const mmStatus = app().mmStatus
   815      if (mmStatus && this.mmRunning === undefined && this.market.base && this.market.quote) {
   816        const { base: { id: baseID }, quote: { id: quoteID }, dex: { host } } = this.market
   817        const botStatus = mmStatus.bots.find(({ config: cfg }) => cfg.baseID === baseID && cfg.quoteID === quoteID && cfg.host === host)
   818        this.mmRunning = Boolean(botStatus?.running)
   819      }
   820  
   821      Doc.setVis(this.mmRunning, page.mmRunning)
   822      if (this.mmRunning) Doc.hide(page.orderForm, page.orderTypeBttns)
   823    }
   824  
   825    /* setLoaderMsgVisibility displays a message in case a dex asset is not
   826     * supported
   827     */
   828    setLoaderMsgVisibility () {
   829      const { page } = this
   830  
   831      const { isSupported, text } = this.assetsAreSupported()
   832      if (isSupported) {
   833        // make sure to hide the loader msg
   834        Doc.hide(page.loaderMsg)
   835        return
   836      }
   837      page.loaderMsg.textContent = text
   838      Doc.show(page.loaderMsg)
   839      Doc.hide(page.notRegistered)
   840      Doc.hide(page.noWallet)
   841    }
   842  
   843    /*
   844     * showTokenApprovalForm displays the form used to give allowance to the
   845     * swap contract of a token.
   846     */
   847    async showTokenApprovalForm (isBase: boolean) {
   848      const assetID = isBase ? this.market.base.id : this.market.quote.id
   849      this.approveTokenForm.setAsset(assetID, this.market.dex.host)
   850      this.forms.show(this.page.approveTokenForm)
   851    }
   852  
   853    /*
   854     * tokenAssetApprovalStatuses returns the approval status of the base and
   855     * quote assets. If the asset is not a token, it is considered approved.
   856     */
   857    tokenAssetApprovalStatuses (): {
   858      baseAssetApprovalStatus: ApprovalStatus;
   859      quoteAssetApprovalStatus: ApprovalStatus;
   860      } {
   861      const { market: { base, quote } } = this
   862      let baseAssetApprovalStatus = ApprovalStatus.Approved
   863      let quoteAssetApprovalStatus = ApprovalStatus.Approved
   864  
   865      if (base?.token) {
   866        const baseAsset = app().assets[base.id]
   867        const baseVersion = this.market.dex.assets[base.id].version
   868        if (baseAsset?.wallet?.approved && baseAsset.wallet.approved[baseVersion] !== undefined) {
   869          baseAssetApprovalStatus = baseAsset.wallet.approved[baseVersion]
   870        }
   871      }
   872      if (quote?.token) {
   873        const quoteAsset = app().assets[quote.id]
   874        const quoteVersion = this.market.dex.assets[quote.id].version
   875        if (quoteAsset?.wallet?.approved && quoteAsset.wallet.approved[quoteVersion] !== undefined) {
   876          quoteAssetApprovalStatus = quoteAsset.wallet.approved[quoteVersion]
   877        }
   878      }
   879  
   880      return {
   881        baseAssetApprovalStatus,
   882        quoteAssetApprovalStatus
   883      }
   884    }
   885  
   886    /*
   887     * setTokenApprovalVisibility sets the visibility of the token approval
   888     * panel elements.
   889     */
   890    setTokenApprovalVisibility () {
   891      const { page } = this
   892  
   893      const { baseAssetApprovalStatus, quoteAssetApprovalStatus } = this.tokenAssetApprovalStatuses()
   894  
   895      if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) {
   896        Doc.hide(page.tokenApproval)
   897        page.sellBttn.removeAttribute('disabled')
   898        page.buyBttn.removeAttribute('disabled')
   899        return
   900      }
   901  
   902      if (baseAssetApprovalStatus !== ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) {
   903        page.sellBttn.setAttribute('disabled', 'disabled')
   904        page.buyBttn.removeAttribute('disabled')
   905        this.setBuy()
   906        Doc.show(page.approvalRequiredSell)
   907        Doc.hide(page.approvalRequiredBuy, page.approvalRequiredBoth)
   908      }
   909  
   910      if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus !== ApprovalStatus.Approved) {
   911        page.buyBttn.setAttribute('disabled', 'disabled')
   912        page.sellBttn.removeAttribute('disabled')
   913        this.setSell()
   914        Doc.show(page.approvalRequiredBuy)
   915        Doc.hide(page.approvalRequiredSell, page.approvalRequiredBoth)
   916      }
   917  
   918      // If they are both unapproved tokens, the order form will not be shown.
   919      if (baseAssetApprovalStatus !== ApprovalStatus.Approved && quoteAssetApprovalStatus !== ApprovalStatus.Approved) {
   920        Doc.show(page.approvalRequiredBoth)
   921        Doc.hide(page.approvalRequiredSell, page.approvalRequiredBuy)
   922      }
   923  
   924      Doc.show(page.tokenApproval)
   925      page.approvalPendingBaseSymbol.textContent = page.baseTokenAsset.textContent = this.market.base.symbol.toUpperCase()
   926      page.approvalPendingQuoteSymbol.textContent = page.quoteTokenAsset.textContent = this.market.quote.symbol.toUpperCase()
   927      Doc.setVis(baseAssetApprovalStatus === ApprovalStatus.NotApproved, page.approveBaseBttn)
   928      Doc.setVis(quoteAssetApprovalStatus === ApprovalStatus.NotApproved, page.approveQuoteBttn)
   929      Doc.setVis(baseAssetApprovalStatus === ApprovalStatus.Pending, page.approvalPendingBase)
   930      Doc.setVis(quoteAssetApprovalStatus === ApprovalStatus.Pending, page.approvalPendingQuote)
   931    }
   932  
   933    /* setRegistrationStatusView sets the text content and class for the
   934     * registration status view
   935     */
   936    setRegistrationStatusView (titleContent: string, confStatusMsg: string, titleClass: string) {
   937      const page = this.page
   938      page.regStatusTitle.textContent = titleContent
   939      page.regStatusConfsDisplay.textContent = confStatusMsg
   940      page.registrationStatus.classList.remove('completed', 'error', 'waiting')
   941      page.registrationStatus.classList.add(titleClass)
   942    }
   943  
   944    /*
   945     * updateRegistrationStatusView updates the view based on the current
   946     * registration status
   947     */
   948    updateRegistrationStatusView () {
   949      const { page, market: { dex } } = this
   950      page.regStatusDex.textContent = dex.host
   951      page.postingBondsDex.textContent = dex.host
   952  
   953      if (dex.auth.effectiveTier >= 1) {
   954        this.setRegistrationStatusView(intl.prep(intl.ID_REGISTRATION_FEE_SUCCESS), '', 'completed')
   955        return
   956      }
   957  
   958      const confStatuses = (dex.auth.pendingBonds || []).map(pending => {
   959        const confirmationsRequired = dex.bondAssets[pending.symbol].confs
   960        return `${pending.confs} / ${confirmationsRequired}`
   961      })
   962      const confStatusMsg = confStatuses.join(', ')
   963      this.setRegistrationStatusView(intl.prep(intl.ID_WAITING_FOR_CONFS), confStatusMsg, 'waiting')
   964    }
   965  
   966    /*
   967     * setRegistrationStatusVisibility toggles the registration status view based
   968     * on the dex data.
   969     */
   970    setRegistrationStatusVisibility () {
   971      const { page, market } = this
   972      if (!market || !market.dex) return
   973  
   974      // If dex is not connected to server, is not possible to know the
   975      // registration status.
   976      if (market.dex.connectionStatus !== ConnectionStatus.Connected) return
   977  
   978      this.updateRegistrationStatusView()
   979  
   980      const showSection = (section: PageElement | undefined) => {
   981        const elements = [page.registrationStatus, page.bondRequired, page.bondCreationPending, page.notRegistered, page.penaltyCompsRequired]
   982        for (const el of elements) {
   983          Doc.setVis(el === section, el)
   984        }
   985      }
   986  
   987      if (market.dex.auth.effectiveTier >= 1) {
   988        const toggle = async () => {
   989          showSection(undefined)
   990          this.resolveOrderFormVisibility()
   991        }
   992        if (Doc.isHidden(page.orderForm)) {
   993          // wait a couple of seconds before showing the form so the success
   994          // message is shown to the user
   995          setTimeout(toggle, 5000)
   996          return
   997        }
   998        toggle()
   999      } else if (market.dex.viewOnly) {
  1000        page.unregisteredDex.textContent = market.dex.host
  1001        showSection(page.notRegistered)
  1002      } else if (market.dex.auth.targetTier > 0 && market.dex.auth.rep.penalties > market.dex.auth.penaltyComps) {
  1003        page.acctPenalties.textContent = `${market.dex.auth.rep.penalties}`
  1004        page.acctPenaltyComps.textContent = `${market.dex.auth.penaltyComps}`
  1005        page.compsDexSettingsLink.href = `/dexsettings/${market.dex.host}`
  1006        showSection(page.penaltyCompsRequired)
  1007      } else if (this.hasPendingBonds()) {
  1008        showSection(page.registrationStatus)
  1009      } else if (market.dex.auth.targetTier > 0) {
  1010        showSection(page.bondCreationPending)
  1011      } else {
  1012        page.acctTier.textContent = `${market.dex.auth.effectiveTier}`
  1013        page.dexSettingsLink.href = `/dexsettings/${market.dex.host}`
  1014        showSection(page.bondRequired)
  1015      }
  1016    }
  1017  
  1018    setOrderBttnText () {
  1019      if (this.isSell()) {
  1020        this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_SELL, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) })
  1021      } else this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_BUY, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) })
  1022    }
  1023  
  1024    setOrderBttnEnabled (isEnabled: boolean, disabledTooltipMsg?: string) {
  1025      const btn = this.page.submitBttn
  1026      if (isEnabled) {
  1027        btn.removeAttribute('disabled')
  1028        btn.removeAttribute('title')
  1029      } else {
  1030        btn.setAttribute('disabled', 'true')
  1031        if (disabledTooltipMsg) btn.setAttribute('title', disabledTooltipMsg)
  1032      }
  1033    }
  1034  
  1035    updateOrderBttnState () {
  1036      const { market: mkt, currentOrder: { qty: orderQty, rate: orderRate, isLimit, sell } } = this
  1037      const baseWallet = app().assets[this.market.base.id].wallet
  1038      const quoteWallet = app().assets[mkt.quote.id].wallet
  1039      if (!baseWallet || !quoteWallet) return
  1040  
  1041      if (orderQty <= 0 || orderQty < mkt.cfg.lotsize) {
  1042        this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR))
  1043        return
  1044      }
  1045  
  1046      // Market orders
  1047      if (!isLimit) {
  1048        if (sell) {
  1049          this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR))
  1050        } else {
  1051          this.setOrderBttnEnabled(orderQty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR))
  1052        }
  1053        return
  1054      }
  1055  
  1056      if (!orderRate) {
  1057        this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR))
  1058        return
  1059      }
  1060  
  1061      // Limit sell
  1062      if (sell) {
  1063        if (baseWallet.balance.available < mkt.cfg.lotsize) {
  1064          this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR))
  1065          return
  1066        }
  1067        if (mkt.maxSell) {
  1068          this.setOrderBttnEnabled(orderQty <= mkt.maxSell.swap.value, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR))
  1069        }
  1070        return
  1071      }
  1072  
  1073      // Limit buy
  1074      const rate = this.adjustedRate()
  1075      const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor)
  1076      if (quoteWallet.balance.available < aLot) {
  1077        this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR))
  1078        return
  1079      }
  1080      if (mkt.maxBuys[rate]) {
  1081        const enable = orderQty <= mkt.maxBuys[rate].swap.lots * mkt.cfg.lotsize
  1082        this.setOrderBttnEnabled(enable, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR))
  1083      }
  1084    }
  1085  
  1086    setCandleDurBttns () {
  1087      const { page, market } = this
  1088      Doc.empty(page.durBttnBox)
  1089      for (const dur of market.dex.candleDurs) {
  1090        const bttn = page.durBttnTemplate.cloneNode(true)
  1091        bttn.textContent = dur
  1092        Doc.bind(bttn, 'click', () => this.candleDurationSelected(dur))
  1093        page.durBttnBox.appendChild(bttn)
  1094      }
  1095  
  1096      // load candlesticks here since we are resetting page.durBttnBox above.
  1097      this.loadCandles()
  1098    }
  1099  
  1100    /* setMarket sets the currently displayed market. */
  1101    async setMarket (host: string, baseID: number, quoteID: number) {
  1102      const dex = app().user.exchanges[host]
  1103      const page = this.page
  1104  
  1105      window.cexBook = async () => {
  1106        const res = await postJSON('/api/cexbook', { host, baseID, quoteID })
  1107        console.log(res.book)
  1108      }
  1109  
  1110      // reset form inputs
  1111      page.lotField.value = ''
  1112      page.qtyField.value = ''
  1113      page.rateField.value = ''
  1114  
  1115      // clear depth chart and orderbook.
  1116      this.depthChart.clear()
  1117      Doc.empty(this.page.buyRows)
  1118      Doc.empty(this.page.sellRows)
  1119  
  1120      // Clear recent matches for the previous market. This will be set when we
  1121      // receive the order book subscription response.
  1122      this.recentMatches = []
  1123      Doc.empty(page.recentMatchesLiveList)
  1124  
  1125      // Hide the balance widget
  1126      this.balanceWgt.setBalanceVisibility(false)
  1127  
  1128      Doc.hide(page.notRegistered, page.bondRequired, page.noWallet, page.penaltyCompsRequired)
  1129  
  1130      // If we have not yet connected, there is no dex.assets or any other
  1131      // exchange data, so just put up a message and wait for the connection to be
  1132      // established, at which time handleConnNote will refresh and reload.
  1133      if (!dex || !dex.markets || dex.connectionStatus !== ConnectionStatus.Connected) {
  1134        let errMsg = intl.prep(intl.ID_CONNECTION_FAILED)
  1135        if (dex.disabled) errMsg = intl.prep(intl.ID_DEX_DISABLED_MSG)
  1136        page.chartErrMsg.textContent = errMsg
  1137        Doc.show(page.chartErrMsg)
  1138        return
  1139      }
  1140  
  1141      for (const s of this.stats) Doc.show(s.row)
  1142  
  1143      const baseCfg = dex.assets[baseID]
  1144      const quoteCfg = dex.assets[quoteID]
  1145  
  1146      const [bui, qui] = [app().unitInfo(baseID, dex), app().unitInfo(quoteID, dex)]
  1147  
  1148      const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor
  1149      Doc.hide(page.maxOrd, page.chartErrMsg)
  1150      if (this.maxEstimateTimer) {
  1151        window.clearTimeout(this.maxEstimateTimer)
  1152        this.maxEstimateTimer = null
  1153      }
  1154      const mktId = marketID(baseCfg.symbol, quoteCfg.symbol)
  1155      const baseAsset = app().assets[baseID]
  1156      const quoteAsset = app().assets[quoteID]
  1157  
  1158      const mkt = {
  1159        dex: dex,
  1160        sid: mktId, // A string market identifier used by the DEX.
  1161        cfg: dex.markets[mktId],
  1162        // app().assets is a map of core.SupportedAsset type, which can be found at
  1163        // client/core/types.go.
  1164        base: baseAsset,
  1165        quote: quoteAsset,
  1166        baseUnitInfo: bui,
  1167        quoteUnitInfo: qui,
  1168        maxSell: null,
  1169        maxBuys: {},
  1170        maxSellRequested: false,
  1171        candleCaches: {},
  1172        baseCfg,
  1173        quoteCfg,
  1174        rateConversionFactor,
  1175        sellBalance: 0,
  1176        buyBalance: 0,
  1177        bookLoaded: false
  1178      }
  1179  
  1180      this.market = mkt
  1181      this.mm.setMarket(host, baseID, quoteID)
  1182      this.mmRunning = undefined
  1183      page.lotSize.textContent = Doc.formatCoinValue(mkt.cfg.lotsize, mkt.baseUnitInfo)
  1184      page.rateStep.textContent = Doc.formatCoinValue(mkt.cfg.ratestep / rateConversionFactor)
  1185  
  1186      this.displayMessageIfMissingWallet()
  1187      this.balanceWgt.setWallets(host, baseID, quoteID)
  1188      this.setMarketDetails()
  1189      this.setCurrMarketPrice()
  1190  
  1191      // if (!dex.candleDurs || dex.candleDurs.length === 0) this.currentChart = depthChart
  1192  
  1193      // depth chart
  1194      ws.request('loadmarket', makeMarket(host, baseID, quoteID))
  1195  
  1196      State.storeLocal(State.lastMarketLK, {
  1197        host: host,
  1198        base: baseID,
  1199        quote: quoteID
  1200      })
  1201      app().updateMarketElements(this.main, baseID, quoteID, dex)
  1202      this.marketList.select(host, baseID, quoteID)
  1203      this.setLoaderMsgVisibility()
  1204      this.setTokenApprovalVisibility()
  1205      this.setRegistrationStatusVisibility()
  1206      this.resolveOrderFormVisibility()
  1207      this.setOrderBttnText()
  1208      this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR))
  1209      this.setCandleDurBttns()
  1210      this.previewQuoteAmt(false)
  1211      this.updateTitle()
  1212      this.reputationMeter.setHost(dex.host)
  1213      this.updateReputation()
  1214      this.loadUserOrders()
  1215    }
  1216  
  1217    /*
  1218      displayMessageForMissingWallet displays a custom message on the market's
  1219      view if one or more of the selected market's wallet is missing.
  1220    */
  1221    displayMessageIfMissingWallet () {
  1222      const page = this.page
  1223      const mkt = this.market
  1224      const baseSym = mkt.baseCfg.symbol.toLocaleUpperCase()
  1225      const quoteSym = mkt.quoteCfg.symbol.toLocaleUpperCase()
  1226      let noWalletMsg = ''
  1227      Doc.hide(page.noWallet)
  1228      if (!mkt.base?.wallet && !mkt.quote?.wallet) noWalletMsg = intl.prep(intl.ID_NO_WALLET_MSG, { asset1: baseSym, asset2: quoteSym })
  1229      else if (!mkt.base?.wallet) noWalletMsg = intl.prep(intl.ID_CREATE_ASSET_WALLET_MSG, { asset: baseSym })
  1230      else if (!mkt.quote?.wallet) noWalletMsg = intl.prep(intl.ID_CREATE_ASSET_WALLET_MSG, { asset: quoteSym })
  1231      else return
  1232  
  1233      page.noWallet.textContent = noWalletMsg
  1234      Doc.show(page.noWallet)
  1235    }
  1236  
  1237    /*
  1238     * reportDepthClick is a callback used by the DepthChart when the user clicks
  1239     * on the chart area. The rate field is set to the x-value of the click.
  1240     */
  1241    reportDepthClick (r: number) {
  1242      this.page.rateField.value = String(r)
  1243      this.rateFieldChanged()
  1244    }
  1245  
  1246    /*
  1247     * reportDepthVolume accepts a volume report from the DepthChart and sets the
  1248     * values in the chart legend.
  1249     */
  1250    reportDepthVolume (r: VolumeReport) {
  1251      const page = this.page
  1252      const { baseUnitInfo: b, quoteUnitInfo: q } = this.market
  1253      // DepthChart reports volumes in conventional units. We'll still use
  1254      // formatCoinValue for formatting though.
  1255      page.sellBookedBase.textContent = Doc.formatCoinValue(r.sellBase * b.conventional.conversionFactor, b)
  1256      page.sellBookedQuote.textContent = Doc.formatCoinValue(r.sellQuote * q.conventional.conversionFactor, q)
  1257      page.buyBookedBase.textContent = Doc.formatCoinValue(r.buyBase * b.conventional.conversionFactor, b)
  1258      page.buyBookedQuote.textContent = Doc.formatCoinValue(r.buyQuote * q.conventional.conversionFactor, q)
  1259    }
  1260  
  1261    /*
  1262     * reportDepthMouse accepts information about the mouse position on the chart
  1263     * area.
  1264     */
  1265    reportDepthMouse (r: MouseReport) {
  1266      while (this.hovers.length) (this.hovers.shift() as HTMLElement).classList.remove('hover')
  1267      const page = this.page
  1268      if (!r) {
  1269        Doc.hide(page.depthLegend)
  1270        return
  1271      }
  1272      Doc.show(page.depthLegend)
  1273  
  1274      // If the user is hovered to within a small percent (based on chart width)
  1275      // of a user order, highlight that order's row.
  1276      for (const { div, ord } of Object.values(this.metaOrders)) {
  1277        if (ord.status !== OrderUtil.StatusBooked) continue
  1278        if (r.hoverMarkers.indexOf(ord.rate) > -1) {
  1279          div.classList.add('hover')
  1280          this.hovers.push(div)
  1281        }
  1282      }
  1283  
  1284      page.hoverPrice.textContent = Doc.formatCoinValue(r.rate)
  1285      page.hoverVolume.textContent = Doc.formatCoinValue(r.depth)
  1286      page.hoverVolume.style.color = r.dotColor
  1287    }
  1288  
  1289    /*
  1290     * reportDepthZoom accepts information about the current depth chart zoom
  1291     * level. This information is saved to disk so that the zoom level can be
  1292     * maintained across reloads.
  1293     */
  1294    reportDepthZoom (zoom: number) {
  1295      State.storeLocal(State.depthZoomLK, zoom)
  1296    }
  1297  
  1298    reportMouseCandle (candle: Candle | null) {
  1299      const page = this.page
  1300      if (!candle) {
  1301        Doc.hide(page.candlesLegend)
  1302        return
  1303      }
  1304      Doc.show(page.candlesLegend)
  1305      page.candleStart.textContent = Doc.formatCoinValue(candle.startRate / this.market.rateConversionFactor)
  1306      page.candleEnd.textContent = Doc.formatCoinValue(candle.endRate / this.market.rateConversionFactor)
  1307      page.candleHigh.textContent = Doc.formatCoinValue(candle.highRate / this.market.rateConversionFactor)
  1308      page.candleLow.textContent = Doc.formatCoinValue(candle.lowRate / this.market.rateConversionFactor)
  1309      page.candleVol.textContent = Doc.formatCoinValue(candle.matchVolume, this.market.baseUnitInfo)
  1310    }
  1311  
  1312    /*
  1313     * parseOrder pulls the order information from the form fields. Data is not
  1314     * validated in any way.
  1315     */
  1316    parseOrder (): TradeForm {
  1317      const page = this.page
  1318      let qtyField = page.qtyField
  1319      const limit = this.isLimit()
  1320      const sell = this.isSell()
  1321      const market = this.market
  1322      let qtyConv = market.baseUnitInfo.conventional.conversionFactor
  1323      if (!limit && !sell) {
  1324        qtyField = page.mktBuyField
  1325        qtyConv = market.quoteUnitInfo.conventional.conversionFactor
  1326      }
  1327      return {
  1328        host: market.dex.host,
  1329        isLimit: limit,
  1330        sell: sell,
  1331        base: market.base.id,
  1332        quote: market.quote.id,
  1333        qty: convertToAtoms(qtyField.value || '', qtyConv),
  1334        rate: convertToAtoms(page.rateField.value || '', market.rateConversionFactor), // message-rate
  1335        tifnow: page.tifNow.checked || false,
  1336        options: {}
  1337      }
  1338    }
  1339  
  1340    /**
  1341     * previewQuoteAmt shows quote amount when rate or quantity input are changed
  1342     */
  1343    previewQuoteAmt (show: boolean) {
  1344      const page = this.page
  1345      if (!this.market.base || !this.market.quote) return // Not a supported asset
  1346      const order = this.currentOrder = this.parseOrder()
  1347      const adjusted = this.adjustedRate()
  1348      page.orderErr.textContent = ''
  1349      if (adjusted) {
  1350        if (order.sell) this.preSell()
  1351        else this.preBuy()
  1352      }
  1353      this.depthLines.input = []
  1354      if (adjusted && this.isLimit()) {
  1355        this.depthLines.input = [{
  1356          rate: order.rate / this.market.rateConversionFactor,
  1357          color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine
  1358        }]
  1359      }
  1360      this.drawChartLines()
  1361      if (!show || !adjusted || !order.qty) {
  1362        page.orderPreview.textContent = ''
  1363        this.drawChartLines()
  1364        return
  1365      }
  1366      const { unitInfo: { conventional: { unit } } } = app().assets[order.quote]
  1367      const quoteQty = order.qty * order.rate / OrderUtil.RateEncodingFactor
  1368      const total = Doc.formatCoinValue(quoteQty, this.market.quoteUnitInfo)
  1369  
  1370      page.orderPreview.textContent = intl.prep(intl.ID_ORDER_PREVIEW, { total, asset: unit })
  1371      if (this.isSell()) this.preSell()
  1372      else this.preBuy()
  1373    }
  1374  
  1375    /**
  1376     * preSell populates the max order message for the largest available sell.
  1377     */
  1378    preSell () {
  1379      const mkt = this.market
  1380      const baseWallet = app().assets[mkt.base.id].wallet
  1381      if (baseWallet.balance.available < mkt.cfg.lotsize) {
  1382        this.setMaxOrder(null)
  1383        this.updateOrderBttnState()
  1384        return
  1385      }
  1386      if (mkt.maxSell) {
  1387        this.setMaxOrder(mkt.maxSell.swap)
  1388        this.updateOrderBttnState()
  1389        return
  1390      }
  1391  
  1392      if (mkt.maxSellRequested) return
  1393      mkt.maxSellRequested = true
  1394      // We only fetch pre-sell once per balance update, so don't delay.
  1395      this.scheduleMaxEstimate('/api/maxsell', {}, 0, (res: MaxSell) => {
  1396        mkt.maxSellRequested = false
  1397        mkt.maxSell = res.maxSell
  1398        mkt.sellBalance = baseWallet.balance.available
  1399        this.setMaxOrder(res.maxSell.swap)
  1400        this.updateOrderBttnState()
  1401      })
  1402    }
  1403  
  1404    /**
  1405     * preBuy populates the max order message for the largest available buy.
  1406     */
  1407    preBuy () {
  1408      const mkt = this.market
  1409      const rate = this.adjustedRate()
  1410      const quoteWallet = app().assets[mkt.quote.id].wallet
  1411      if (!quoteWallet) return
  1412      const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor)
  1413      if (quoteWallet.balance.available < aLot) {
  1414        this.setMaxOrder(null)
  1415        this.updateOrderBttnState()
  1416        return
  1417      }
  1418      if (mkt.maxBuys[rate]) {
  1419        this.setMaxOrder(mkt.maxBuys[rate].swap)
  1420        this.updateOrderBttnState()
  1421        return
  1422      }
  1423      // 0 delay for first fetch after balance update or market change, otherwise
  1424      // meter these at 1 / sec.
  1425      const delay = Object.keys(mkt.maxBuys).length ? 350 : 0
  1426      this.scheduleMaxEstimate('/api/maxbuy', { rate }, delay, (res: MaxBuy) => {
  1427        mkt.maxBuys[rate] = res.maxBuy
  1428        mkt.buyBalance = app().assets[mkt.quote.id].wallet.balance.available
  1429        this.setMaxOrder(res.maxBuy.swap)
  1430        this.updateOrderBttnState()
  1431      })
  1432    }
  1433  
  1434    /**
  1435     * scheduleMaxEstimate shows the loading icon and schedules a call to an order
  1436     * estimate api endpoint. If another call to scheduleMaxEstimate is made before
  1437     * this one is fired (after delay), this call will be canceled.
  1438     */
  1439    scheduleMaxEstimate (path: string, args: any, delay: number, success: (res: any) => void) {
  1440      const page = this.page
  1441      if (!this.maxLoaded) this.maxLoaded = app().loading(page.maxOrd)
  1442      const [bid, qid] = [this.market.base.id, this.market.quote.id]
  1443      const [bWallet, qWallet] = [app().assets[bid].wallet, app().assets[qid].wallet]
  1444      if (!bWallet || !bWallet.running || !qWallet || !qWallet.running) return
  1445      if (this.maxEstimateTimer) window.clearTimeout(this.maxEstimateTimer)
  1446  
  1447      Doc.show(page.maxOrd, page.maxLotBox)
  1448      Doc.hide(page.maxAboveZero, page.maxZeroNoFees, page.maxZeroNoBal)
  1449      page.maxFromLots.textContent = intl.prep(intl.ID_CALCULATING)
  1450      page.maxFromLotsLbl.textContent = ''
  1451      this.maxOrderUpdateCounter++
  1452      const counter = this.maxOrderUpdateCounter
  1453      this.maxEstimateTimer = window.setTimeout(async () => {
  1454        this.maxEstimateTimer = null
  1455        if (counter !== this.maxOrderUpdateCounter) return
  1456        const res = await postJSON(path, {
  1457          host: this.market.dex.host,
  1458          base: bid,
  1459          quote: qid,
  1460          ...args
  1461        })
  1462        if (counter !== this.maxOrderUpdateCounter) return
  1463        if (!app().checkResponse(res)) {
  1464          console.warn('max order estimate not available:', res)
  1465          page.maxFromLots.textContent = intl.prep(intl.ID_ESTIMATE_UNAVAILABLE)
  1466          if (this.maxLoaded) {
  1467            this.maxLoaded()
  1468            this.maxLoaded = null
  1469          }
  1470          return
  1471        }
  1472        success(res)
  1473      }, delay)
  1474    }
  1475  
  1476    /* setMaxOrder sets the max order text. */
  1477    setMaxOrder (maxOrder: SwapEstimate | null) {
  1478      const page = this.page
  1479      if (this.maxLoaded) {
  1480        this.maxLoaded()
  1481        this.maxLoaded = null
  1482      }
  1483      Doc.show(page.maxOrd, page.maxLotBox)
  1484      const sell = this.isSell()
  1485  
  1486      let lots = 0
  1487      if (maxOrder) lots = maxOrder.lots
  1488  
  1489      page.maxFromLots.textContent = lots.toString()
  1490      // XXX add plural into format details, so we don't need this
  1491      page.maxFromLotsLbl.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS)
  1492      if (!maxOrder) return
  1493  
  1494      const fromAsset = sell ? this.market.base : this.market.quote
  1495  
  1496      if (lots === 0) {
  1497        // If we have a maxOrder, see if we can guess why we have no lots.
  1498        let lotSize = this.market.cfg.lotsize
  1499        if (!sell) {
  1500          const conversionRate = this.anyRate()[1]
  1501          if (conversionRate === 0) return
  1502          lotSize = lotSize * conversionRate
  1503        }
  1504        const haveQty = fromAsset.wallet.balance.available / lotSize > 0
  1505        if (haveQty) {
  1506          if (fromAsset.token) {
  1507            const { wallet: { balance: { available: feeAvail } }, unitInfo } = app().assets[fromAsset.token.parentID]
  1508            if (feeAvail < maxOrder.feeReservesPerLot) {
  1509              Doc.show(page.maxZeroNoFees)
  1510              page.maxZeroNoFeesTicker.textContent = unitInfo.conventional.unit
  1511              page.maxZeroMinFees.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo)
  1512            }
  1513            // It looks like we should be able to afford it, but maybe some fees we're not seeing.
  1514            // Show nothing.
  1515            return
  1516          }
  1517          // Not a token. Maybe we have enough for the swap but not for fees.
  1518          const fundedLots = fromAsset.wallet.balance.available / (lotSize + maxOrder.feeReservesPerLot)
  1519          if (fundedLots > 0) return // Not sure why. Could be split txs or utxos. Just show nothing.
  1520        }
  1521        Doc.show(page.maxZeroNoBal)
  1522        page.maxZeroNoBalTicker.textContent = fromAsset.unitInfo.conventional.unit
  1523        return
  1524      }
  1525      Doc.show(page.maxAboveZero)
  1526  
  1527      page.maxFromAmt.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo)
  1528      page.maxFromTicker.textContent = fromAsset.unitInfo.conventional.unit
  1529      // Could subtract the maxOrder.redemptionFees here.
  1530      // The qty conversion doesn't fit well with the new design.
  1531      // TODO: Make this work somehow?
  1532      // const toConversion = sell ? this.adjustedRate() / OrderUtil.RateEncodingFactor : OrderUtil.RateEncodingFactor / this.adjustedRate()
  1533      // page.maxToAmt.textContent = Doc.formatCoinValue((maxOrder.value || 0) * toConversion, toAsset.unitInfo)
  1534      // page.maxToTicker.textContent = toAsset.symbol.toUpperCase()
  1535    }
  1536  
  1537    /*
  1538     * validateOrder performs some basic order sanity checks, returning boolean
  1539     * true if the order appears valid.
  1540     */
  1541    validateOrder (order: TradeForm) {
  1542      const { page, market: { cfg: { minimumRate }, rateConversionFactor } } = this
  1543      if (order.isLimit) {
  1544        if (!order.rate) {
  1545          Doc.show(page.orderErr)
  1546          page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_RATE)
  1547          return false
  1548        }
  1549        if (order.rate < minimumRate) {
  1550          Doc.show(page.orderErr)
  1551          const [r, minRate] = [order.rate / rateConversionFactor, minimumRate / rateConversionFactor]
  1552          page.orderErr.textContent = `rate is lower than the market's minimum rate. ${r} < ${minRate}`
  1553          return false
  1554        }
  1555      }
  1556      if (!order.qty) {
  1557        Doc.show(page.orderErr)
  1558        page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_QUANTITY)
  1559        return false
  1560      }
  1561      return true
  1562    }
  1563  
  1564    /* handleBook accepts the data sent in the 'book' notification. */
  1565    handleBook (data: MarketOrderBook) {
  1566      const { cfg, baseUnitInfo, quoteUnitInfo, baseCfg, quoteCfg } = this.market
  1567      this.book = new OrderBook(data, baseCfg.symbol, quoteCfg.symbol)
  1568      this.loadTable()
  1569      for (const order of (data.book.epoch || [])) {
  1570        if (order.rate > 0) this.book.add(order)
  1571        this.addTableOrder(order)
  1572      }
  1573      if (!this.book) {
  1574        this.depthChart.clear()
  1575        Doc.empty(this.page.buyRows)
  1576        Doc.empty(this.page.sellRows)
  1577        return
  1578      }
  1579      Doc.show(this.page.epochLine)
  1580      if (this.loadingAnimations.depth) this.loadingAnimations.depth.stop()
  1581      this.depthChart.canvas.classList.remove('invisible')
  1582      this.depthChart.set(this.book, cfg.lotsize, cfg.ratestep, baseUnitInfo, quoteUnitInfo)
  1583      this.recentMatches = data.book.recentMatches ?? []
  1584      this.refreshRecentMatchesTable()
  1585    }
  1586  
  1587    /*
  1588     * midGapConventional is the same as midGap, but returns the mid-gap rate as
  1589     * the conventional ratio. This is used to convert from a conventional
  1590     * quantity from base to quote or vice-versa, or for display purposes.
  1591     */
  1592    midGapConventional () {
  1593      const gap = this.midGap()
  1594      if (!gap) return gap
  1595      const { baseUnitInfo: b, quoteUnitInfo: q } = this.market
  1596      return gap * b.conventional.conversionFactor / q.conventional.conversionFactor
  1597    }
  1598  
  1599    /*
  1600     * midGap returns the value in the middle of the best buy and best sell. If
  1601     * either one of the buy or sell sides are empty, midGap returns the best rate
  1602     * from the other side. If both sides are empty, midGap returns the value
  1603     * null. The rate returned is the atomic ratio, used for conversion. For a
  1604     * conventional rate for display or to convert conventional units, use
  1605     * midGapConventional
  1606     */
  1607    midGap () {
  1608      const book = this.book
  1609      if (!book) return
  1610      if (book.buys && book.buys.length) {
  1611        if (book.sells && book.sells.length) {
  1612          return (book.buys[0].msgRate + book.sells[0].msgRate) / 2 / OrderUtil.RateEncodingFactor
  1613        }
  1614        return book.buys[0].msgRate / OrderUtil.RateEncodingFactor
  1615      }
  1616      if (book.sells && book.sells.length) {
  1617        return book.sells[0].msgRate / OrderUtil.RateEncodingFactor
  1618      }
  1619      return null
  1620    }
  1621  
  1622    /*
  1623     * setMarketBuyOrderEstimate sets the "min. buy" display for the current
  1624     * market.
  1625     */
  1626    setMarketBuyOrderEstimate () {
  1627      const market = this.market
  1628      const lotSize = market.cfg.lotsize
  1629      const xc = app().user.exchanges[market.dex.host]
  1630      const buffer = xc.markets[market.sid].buybuffer
  1631      const gap = this.midGapConventional()
  1632      if (gap) {
  1633        this.page.minMktBuy.textContent = Doc.formatCoinValue(lotSize * buffer * gap, market.baseUnitInfo)
  1634      }
  1635    }
  1636  
  1637    maxUserOrderCount (): number {
  1638      const { dex: { host }, cfg: { name: mktID } } = this.market
  1639      return Math.max(maxUserOrdersShown, app().orders(host, mktID).length)
  1640    }
  1641  
  1642    async loadUserOrders () {
  1643      const { base: b, quote: q, dex: { host }, cfg: { name: mktID } } = this.market
  1644      for (const oid in this.metaOrders) delete this.metaOrders[oid]
  1645      if (!b || !q) return this.resolveUserOrders([]) // unsupported asset
  1646      const activeOrders = app().orders(host, mktID)
  1647      if (activeOrders.length >= maxUserOrdersShown) return this.resolveUserOrders(activeOrders)
  1648      const filter: OrderFilter = {
  1649        hosts: [host],
  1650        market: { baseID: b.id, quoteID: q.id },
  1651        n: this.maxUserOrderCount()
  1652      }
  1653      const res = await postJSON('/api/orders', filter)
  1654      const orders = res.orders || []
  1655      // Make sure all active orders are in there. The /orders API sorts by time,
  1656      // so if there is are 10 cancelled/executed orders newer than an old active
  1657      // order, the active order wouldn't be included in the result.
  1658      for (const activeOrd of activeOrders) if (!orders.some((dbOrd: Order) => dbOrd.id === activeOrd.id)) orders.push(activeOrd)
  1659      return this.resolveUserOrders(res.orders || [])
  1660    }
  1661  
  1662    /* refreshActiveOrders refreshes the user's active order list. */
  1663    refreshActiveOrders () {
  1664      const orders = app().orders(this.market.dex.host, marketID(this.market.baseCfg.symbol, this.market.quoteCfg.symbol))
  1665      return this.resolveUserOrders(orders)
  1666    }
  1667  
  1668    resolveUserOrders (orders: Order[]) {
  1669      const { page, metaOrders, market } = this
  1670      const cfg = market.cfg
  1671  
  1672      const orderIsActive = (ord: Order) => ord.status < OrderUtil.StatusExecuted || OrderUtil.hasActiveMatches(ord)
  1673  
  1674      for (const ord of orders) metaOrders[ord.id] = { ord: ord } as MetaOrder
  1675      let sortedOrders = Object.keys(metaOrders).map((oid: string) => metaOrders[oid])
  1676      sortedOrders.sort((a: MetaOrder, b: MetaOrder) => {
  1677        const [aActive, bActive] = [orderIsActive(a.ord), orderIsActive(b.ord)]
  1678        if (aActive && !bActive) return -1
  1679        else if (!aActive && bActive) return 1
  1680        return b.ord.submitTime - a.ord.submitTime
  1681      })
  1682      const n = this.maxUserOrderCount()
  1683      if (sortedOrders.length > n) { sortedOrders = sortedOrders.slice(0, n) }
  1684  
  1685      for (const oid in metaOrders) delete metaOrders[oid]
  1686  
  1687      Doc.empty(page.userOrders)
  1688      Doc.setVis(sortedOrders?.length, page.userOrders)
  1689      Doc.setVis(!sortedOrders?.length, page.userNoOrders)
  1690  
  1691      let unreadyOrders = false
  1692      for (const mord of sortedOrders) {
  1693        const div = page.userOrderTmpl.cloneNode(true) as HTMLElement
  1694        page.userOrders.appendChild(div)
  1695        const tmpl = Doc.parseTemplate(div)
  1696        const header = Doc.parseTemplate(tmpl.header)
  1697        const details = Doc.parseTemplate(tmpl.details)
  1698  
  1699        mord.div = div
  1700        mord.header = header
  1701        mord.details = details
  1702        const ord = mord.ord
  1703  
  1704        const orderID = ord.id
  1705        const isActive = orderIsActive(ord)
  1706  
  1707        // No need to track in-flight orders here. We've already added it to
  1708        // display.
  1709        if (orderID) metaOrders[orderID] = mord
  1710  
  1711        if (!ord.readyToTick && OrderUtil.hasActiveMatches(ord)) {
  1712          tmpl.header.classList.add('unready-user-order')
  1713          unreadyOrders = true
  1714        }
  1715        header.sideLight.classList.add(ord.sell ? 'sell' : 'buy')
  1716        if (!isActive) header.sideLight.classList.add('inactive')
  1717        details.side.textContent = mord.header.side.textContent = OrderUtil.sellString(ord)
  1718        details.side.classList.add(ord.sell ? 'sellcolor' : 'buycolor')
  1719        header.side.classList.add(ord.sell ? 'sellcolor' : 'buycolor')
  1720        details.qty.textContent = mord.header.qty.textContent = Doc.formatCoinValue(ord.qty, market.baseUnitInfo)
  1721        let rateStr: string
  1722        if (ord.type === OrderUtil.Market) rateStr = this.marketOrderRateString(ord, market)
  1723        else rateStr = Doc.formatRateFullPrecision(ord.rate, market.baseUnitInfo, market.quoteUnitInfo, cfg.ratestep)
  1724        details.rate.textContent = mord.header.rate.textContent = rateStr
  1725        header.baseSymbol.textContent = market.baseUnitInfo.conventional.unit
  1726        details.type.textContent = OrderUtil.orderTypeText(ord.type)
  1727        this.updateMetaOrder(mord)
  1728  
  1729        Doc.bind(div, 'mouseenter', () => {
  1730          this.activeMarkerRate = ord.rate
  1731          this.setDepthMarkers()
  1732        })
  1733  
  1734        const showCancel = (e: Event) => {
  1735          e.stopPropagation()
  1736          this.showCancel(div, orderID)
  1737        }
  1738  
  1739        const showAccelerate = (e: Event) => {
  1740          e.stopPropagation()
  1741          this.showAccelerate(ord)
  1742        }
  1743  
  1744        if (!orderID) {
  1745          Doc.hide(details.accelerateBttn)
  1746          Doc.hide(details.cancelBttn)
  1747          Doc.hide(details.link)
  1748        } else {
  1749          if (OrderUtil.isCancellable(ord)) {
  1750            Doc.show(details.cancelBttn)
  1751            bind(details.cancelBttn, 'click', (e: Event) => { showCancel(e) })
  1752          }
  1753  
  1754          bind(details.accelerateBttn, 'click', (e: Event) => { showAccelerate(e) })
  1755          if (app().canAccelerateOrder(ord)) {
  1756            Doc.show(details.accelerateBttn)
  1757          }
  1758  
  1759          details.link.href = `order/${orderID}`
  1760          app().bindInternalNavigation(div)
  1761        }
  1762        let currentFloater: (PageElement | null)
  1763        Doc.bind(tmpl.header, 'click', () => {
  1764          if (Doc.isDisplayed(tmpl.details)) {
  1765            Doc.hide(tmpl.details)
  1766            header.expander.classList.add('ico-arrowdown')
  1767            header.expander.classList.remove('ico-arrowup')
  1768            return
  1769          }
  1770          Doc.show(tmpl.details)
  1771          header.expander.classList.remove('ico-arrowdown')
  1772          header.expander.classList.add('ico-arrowup')
  1773          if (currentFloater) currentFloater.remove()
  1774        })
  1775        /**
  1776         * We'll show the button menu when they hover over the header. To avoid
  1777         * pushing the layout around, we'll show the buttons as an absolutely
  1778         * positioned copy of the button menu.
  1779         */
  1780        Doc.bind(tmpl.header, 'mouseenter', () => {
  1781          // Don't show the copy if the details are already displayed.
  1782          if (Doc.isDisplayed(tmpl.details)) return
  1783          if (currentFloater) currentFloater.remove()
  1784          // Create and position the element based on the position of the header.
  1785          const floater = document.createElement('div')
  1786          currentFloater = floater
  1787          document.body.appendChild(floater)
  1788          floater.className = 'user-order-floaty-menu'
  1789          const m = Doc.layoutMetrics(tmpl.header)
  1790          const y = m.bodyTop + m.height
  1791          floater.style.top = `${y - 1}px` // - 1 to hide border on header div
  1792          floater.style.left = `${m.bodyLeft}px`
  1793          // Get the updated version of the order
  1794          const mord = this.metaOrders[orderID]
  1795          const ord = mord.ord
  1796  
  1797          const addButton = (baseBttn: PageElement, cb: ((e: Event) => void)) => {
  1798            const icon = baseBttn.cloneNode(true) as PageElement
  1799            floater.appendChild(icon)
  1800            Doc.show(icon)
  1801            Doc.bind(icon, 'click', (e: Event) => { cb(e) })
  1802          }
  1803  
  1804          if (OrderUtil.isCancellable(ord)) addButton(details.cancelBttn, (e: Event) => { showCancel(e) })
  1805          if (app().canAccelerateOrder(ord)) addButton(details.accelerateBttn, (e: Event) => { showAccelerate(e) })
  1806          floater.appendChild(details.link.cloneNode(true))
  1807  
  1808          const ogScrollY = page.orderScroller.scrollTop
  1809          // Set up the hover interactions.
  1810          const moved = (e: MouseEvent) => {
  1811            // If the user scrolled, reposition the float menu. This keeps the
  1812            // menu from following us around, which can prevent removal below.
  1813            const yShift = page.orderScroller.scrollTop - ogScrollY
  1814            floater.style.top = `${y + yShift}px`
  1815            if (Doc.mouseInElement(e, floater) || Doc.mouseInElement(e, div)) return
  1816            floater.remove()
  1817            currentFloater = null
  1818            document.removeEventListener('mousemove', moved)
  1819            page.orderScroller.removeEventListener('scroll', moved)
  1820          }
  1821          document.addEventListener('mousemove', moved)
  1822          page.orderScroller.addEventListener('scroll', moved)
  1823        })
  1824        app().bindTooltips(div)
  1825      }
  1826      Doc.setVis(unreadyOrders, page.unreadyOrdersMsg)
  1827      this.setDepthMarkers()
  1828    }
  1829  
  1830    /*
  1831     marketOrderRateString uses the market config rate step to format the average
  1832     market order rate.
  1833    */
  1834    marketOrderRateString (ord: Order, mkt: CurrentMarket) :string {
  1835      if (!ord.matches?.length) return intl.prep(intl.ID_MARKET_ORDER)
  1836      let rateStr = Doc.formatRateFullPrecision(OrderUtil.averageRate(ord), mkt.baseUnitInfo, mkt.quoteUnitInfo, mkt.cfg.ratestep)
  1837      if (ord.matches.length > 1) rateStr = '~ ' + rateStr // ~ only makes sense if the order has more than one match
  1838      return rateStr
  1839    }
  1840  
  1841    /*
  1842    * updateMetaOrder sets the td contents of the user's order table row.
  1843    */
  1844    updateMetaOrder (mord: MetaOrder) {
  1845      const { header, details, ord } = mord
  1846      if (ord.status <= OrderUtil.StatusBooked || OrderUtil.hasActiveMatches(ord)) header.activeLight.classList.add('active')
  1847      else header.activeLight.classList.remove('active')
  1848      details.status.textContent = header.status.textContent = OrderUtil.statusString(ord)
  1849      details.age.textContent = Doc.timeSince(ord.submitTime)
  1850      details.filled.textContent = `${(OrderUtil.filled(ord) / ord.qty * 100).toFixed(1)}%`
  1851      details.settled.textContent = `${(OrderUtil.settled(ord) / ord.qty * 100).toFixed(1)}%`
  1852    }
  1853  
  1854    /* setMarkers sets the depth chart markers for booked orders. */
  1855    setDepthMarkers () {
  1856      const markers: Record<string, DepthMarker[]> = {
  1857        buys: [],
  1858        sells: []
  1859      }
  1860      const rateFactor = this.market.rateConversionFactor
  1861      for (const { ord } of Object.values(this.metaOrders)) {
  1862        if (ord.rate && ord.status === OrderUtil.StatusBooked) {
  1863          if (ord.sell) {
  1864            markers.sells.push({
  1865              rate: ord.rate / rateFactor,
  1866              active: ord.rate === this.activeMarkerRate
  1867            })
  1868          } else {
  1869            markers.buys.push({
  1870              rate: ord.rate / rateFactor,
  1871              active: ord.rate === this.activeMarkerRate
  1872            })
  1873          }
  1874        }
  1875      }
  1876      this.depthChart.setMarkers(markers)
  1877      if (this.book) this.depthChart.draw()
  1878    }
  1879  
  1880    /* updateTitle update the browser title based on the midgap value and the
  1881     * selected assets.
  1882     */
  1883    updateTitle () {
  1884      // gets first price value from buy or from sell, so we can show it on
  1885      // title.
  1886      const midGapValue = this.midGapConventional()
  1887      const { baseUnitInfo: { conventional: { unit: bUnit } }, quoteUnitInfo: { conventional: { unit: qUnit } } } = this.market
  1888      if (!midGapValue) document.title = `${bUnit}${qUnit} | ${this.ogTitle}`
  1889      else document.title = `${Doc.formatCoinValue(midGapValue)} | ${bUnit}${qUnit} | ${this.ogTitle}` // more than 6 numbers it gets too big for the title.
  1890    }
  1891  
  1892    /* handleBookRoute is the handler for the 'book' notification, which is sent
  1893     * in response to a new market subscription. The data received will contain
  1894     * the entire order book.
  1895     */
  1896    handleBookRoute (note: BookUpdate) {
  1897      app().log('book', 'handleBookRoute:', note)
  1898      const mktBook = note.payload
  1899      const { baseCfg: b, quoteCfg: q, dex: { host } } = this.market
  1900      if (mktBook.base !== b.id || mktBook.quote !== q.id || note.host !== host) return // user already changed markets
  1901      this.handleBook(mktBook)
  1902      this.market.bookLoaded = true
  1903      this.updateTitle()
  1904      this.setMarketBuyOrderEstimate()
  1905    }
  1906  
  1907    /* handleBookOrderRoute is the handler for 'book_order' notifications. */
  1908    handleBookOrderRoute (data: BookUpdate) {
  1909      app().log('book', 'handleBookOrderRoute:', data)
  1910      if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return
  1911      const order = data.payload as MiniOrder
  1912      if (order.rate > 0) this.book.add(order)
  1913      this.addTableOrder(order)
  1914      this.updateTitle()
  1915      this.depthChart.draw()
  1916    }
  1917  
  1918    /* handleUnbookOrderRoute is the handler for 'unbook_order' notifications. */
  1919    handleUnbookOrderRoute (data: BookUpdate) {
  1920      app().log('book', 'handleUnbookOrderRoute:', data)
  1921      if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return
  1922      const order = data.payload
  1923      this.book.remove(order.token)
  1924      this.removeTableOrder(order)
  1925      this.updateTitle()
  1926      this.depthChart.draw()
  1927    }
  1928  
  1929    /*
  1930     * handleUpdateRemainingRoute is the handler for 'update_remaining'
  1931     * notifications.
  1932     */
  1933    handleUpdateRemainingRoute (data: BookUpdate) {
  1934      app().log('book', 'handleUpdateRemainingRoute:', data)
  1935      if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return
  1936      const update = data.payload
  1937      this.book.updateRemaining(update.token, update.qty, update.qtyAtomic)
  1938      this.updateTableOrder(update)
  1939      this.depthChart.draw()
  1940    }
  1941  
  1942    /* handleEpochOrderRoute is the handler for 'epoch_order' notifications. */
  1943    handleEpochOrderRoute (data: BookUpdate) {
  1944      app().log('book', 'handleEpochOrderRoute:', data)
  1945      if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return
  1946      const order = data.payload
  1947      if (order.msgRate > 0) this.book.add(order) // No cancels or market orders
  1948      if (order.qtyAtomic > 0) this.addTableOrder(order) // No cancel orders
  1949      this.depthChart.draw()
  1950    }
  1951  
  1952    /* handleCandlesRoute is the handler for 'candles' notifications. */
  1953    handleCandlesRoute (data: BookUpdate) {
  1954      if (this.candlesLoading) {
  1955        clearTimeout(this.candlesLoading.timer)
  1956        this.candlesLoading.loaded()
  1957        this.candlesLoading = null
  1958      }
  1959      if (data.host !== this.market.dex.host || data.marketID !== this.market.cfg.name) return
  1960      const dur = data.payload.dur
  1961      this.market.candleCaches[dur] = data.payload
  1962      this.setHighLow()
  1963      if (this.candleDur !== dur) return
  1964      if (this.loadingAnimations.candles) this.loadingAnimations.candles.stop()
  1965      this.candleChart.canvas.classList.remove('invisible')
  1966      this.candleChart.setCandles(data.payload, this.market.cfg, this.market.baseUnitInfo, this.market.quoteUnitInfo)
  1967    }
  1968  
  1969    handleEpochMatchSummary (data: BookUpdate) {
  1970      this.addRecentMatches(data.payload.matchSummaries)
  1971      this.refreshRecentMatchesTable()
  1972    }
  1973  
  1974    /* handleCandleUpdateRoute is the handler for 'candle_update' notifications. */
  1975    handleCandleUpdateRoute (data: BookUpdate) {
  1976      if (data.host !== this.market.dex.host) return
  1977      const { dur, candle } = data.payload
  1978      const cache = this.market.candleCaches[dur]
  1979      if (!cache) return // must not have seen the 'candles' notification yet?
  1980      const candles = cache.candles
  1981      if (candles.length === 0) candles.push(candle)
  1982      else {
  1983        const last = candles[candles.length - 1]
  1984        if (last.startStamp === candle.startStamp) candles[candles.length - 1] = candle
  1985        else candles.push(candle)
  1986      }
  1987      if (this.candleDur !== dur) return
  1988      this.candleChart.draw()
  1989    }
  1990  
  1991    /*
  1992     * showToggleWalletStatus displays the toggleWalletStatusConfirm form to
  1993     * enable a wallet.
  1994     */
  1995    showToggleWalletStatus (asset: SupportedAsset) {
  1996      const page = this.page
  1997      this.openAsset = asset
  1998      Doc.hide(page.toggleWalletStatusErr, page.walletStatusDisable, page.disableWalletMsg)
  1999      Doc.show(page.walletStatusEnable, page.enableWalletMsg)
  2000      this.forms.show(page.toggleWalletStatusConfirm)
  2001    }
  2002  
  2003    /*
  2004     * toggleWalletStatus toggle wallets status to enabled.
  2005     */
  2006    async toggleWalletStatus () {
  2007      const page = this.page
  2008      Doc.hide(page.toggleWalletStatusErr)
  2009  
  2010      const url = '/api/togglewalletstatus'
  2011      const req = {
  2012        assetID: this.openAsset.id,
  2013        disable: false
  2014      }
  2015  
  2016      const loaded = app().loading(page.toggleWalletStatusConfirm)
  2017      const res = await postJSON(url, req)
  2018      loaded()
  2019      if (!app().checkResponse(res)) {
  2020        page.toggleWalletStatusErr.textContent = res.msg
  2021        Doc.show(page.toggleWalletStatusErr)
  2022        return
  2023      }
  2024  
  2025      Doc.hide(this.page.forms)
  2026      this.balanceWgt.updateAsset(this.openAsset.id)
  2027    }
  2028  
  2029    /* showVerify shows the form to accept the currently parsed order information
  2030     * and confirm submission of the order to the dex.
  2031     */
  2032    showVerify () {
  2033      this.preorderCache = {}
  2034      const page = this.page
  2035      const order = this.currentOrder = this.parseOrder()
  2036      const isSell = order.sell
  2037      const baseAsset = app().assets[order.base]
  2038      const quoteAsset = app().assets[order.quote]
  2039      const toAsset = isSell ? quoteAsset : baseAsset
  2040      const fromAsset = isSell ? baseAsset : quoteAsset
  2041  
  2042      const setIcon = (icon: PageElement) => {
  2043        switch (icon.dataset.icon) {
  2044          case 'from':
  2045            if (fromAsset.token) {
  2046              const parentAsset = app().assets[fromAsset.token.parentID]
  2047              icon.src = Doc.logoPath(parentAsset.symbol)
  2048            } else {
  2049              icon.src = Doc.logoPath(fromAsset.symbol)
  2050            }
  2051            break
  2052          case 'to':
  2053            if (toAsset.token) {
  2054              const parentAsset = app().assets[toAsset.token.parentID]
  2055              icon.src = Doc.logoPath(parentAsset.symbol)
  2056            } else {
  2057              icon.src = Doc.logoPath(toAsset.symbol)
  2058            }
  2059        }
  2060      }
  2061  
  2062      // Set the to and from icons in the fee details pane.
  2063      for (const icon of Doc.applySelector(page.vDetailPane, '[data-icon]')) {
  2064        setIcon(icon)
  2065      }
  2066  
  2067      // Set the to and from icons in the fee summary pane.
  2068      for (const icon of Doc.applySelector(page.vFeeSummary, '[data-icon]')) {
  2069        setIcon(icon)
  2070      }
  2071  
  2072      Doc.hide(page.vPreorderErr)
  2073      Doc.show(page.vPreorder)
  2074  
  2075      page.vBuySell.textContent = isSell ? intl.prep(intl.ID_SELLING) : intl.prep(intl.ID_BUYING)
  2076      const buySellStr = isSell ? intl.prep(intl.ID_SELL) : intl.prep(intl.ID_BUY)
  2077      page.vSideSubmit.textContent = buySellStr
  2078      page.vOrderHost.textContent = order.host
  2079      if (order.isLimit) {
  2080        Doc.show(page.verifyLimit)
  2081        Doc.hide(page.verifyMarket)
  2082        const orderDesc = `Limit ${buySellStr} Order`
  2083        page.vOrderType.textContent = order.tifnow ? orderDesc + ' (immediate)' : orderDesc
  2084        page.vRate.textContent = Doc.formatCoinValue(order.rate / this.market.rateConversionFactor)
  2085        page.vQty.textContent = Doc.formatCoinValue(order.qty, baseAsset.unitInfo)
  2086        const total = order.rate / OrderUtil.RateEncodingFactor * order.qty
  2087        page.vTotal.textContent = Doc.formatCoinValue(total, quoteAsset.unitInfo)
  2088        // Format total fiat value.
  2089        this.showFiatValue(quoteAsset.id, total, page.vFiatTotal)
  2090      } else {
  2091        Doc.hide(page.verifyLimit)
  2092        Doc.show(page.verifyMarket)
  2093        page.vOrderType.textContent = `Market ${buySellStr} Order`
  2094        const ui = order.sell ? this.market.baseUnitInfo : this.market.quoteUnitInfo
  2095        page.vmFromTotal.textContent = Doc.formatCoinValue(order.qty, ui)
  2096        page.vmFromAsset.textContent = fromAsset.symbol.toUpperCase()
  2097        // Format fromAsset fiat value.
  2098        this.showFiatValue(fromAsset.id, order.qty, page.vmFromTotalFiat)
  2099        const gap = this.midGap()
  2100        if (gap) {
  2101          Doc.show(page.vMarketEstimate)
  2102          const received = order.sell ? order.qty * gap : order.qty / gap
  2103          page.vmToTotal.textContent = Doc.formatCoinValue(received, toAsset.unitInfo)
  2104          page.vmToAsset.textContent = toAsset.symbol.toUpperCase()
  2105          // Format received value to fiat equivalent.
  2106          this.showFiatValue(toAsset.id, received, page.vmTotalFiat)
  2107        } else {
  2108          Doc.hide(page.vMarketEstimate)
  2109        }
  2110      }
  2111      // Visually differentiate between buy/sell orders.
  2112      if (isSell) {
  2113        page.vHeader.classList.add(sellBtnClass)
  2114        page.vHeader.classList.remove(buyBtnClass)
  2115        page.vSubmit.classList.add(sellBtnClass)
  2116        page.vSubmit.classList.remove(buyBtnClass)
  2117      } else {
  2118        page.vHeader.classList.add(buyBtnClass)
  2119        page.vHeader.classList.remove(sellBtnClass)
  2120        page.vSubmit.classList.add(buyBtnClass)
  2121        page.vSubmit.classList.remove(sellBtnClass)
  2122      }
  2123      this.showVerifyForm()
  2124  
  2125      if (baseAsset.wallet.open && quoteAsset.wallet.open) this.preOrder(order)
  2126      else {
  2127        Doc.hide(page.vPreorder)
  2128        this.unlockWalletsForEstimates()
  2129      }
  2130    }
  2131  
  2132    // showFiatValue displays the fiat equivalent for an order quantity.
  2133    showFiatValue (assetID: number, qty: number, display: PageElement) {
  2134      if (display) {
  2135        const rate = app().fiatRatesMap[assetID]
  2136        display.textContent = Doc.formatFiatConversion(qty, rate, app().unitInfo(assetID))
  2137        if (rate) Doc.show(display.parentElement as Element)
  2138        else Doc.hide(display.parentElement as Element)
  2139      }
  2140    }
  2141  
  2142    /* showVerifyForm displays form to verify an order */
  2143    async showVerifyForm () {
  2144      const page = this.page
  2145      Doc.hide(page.vErr)
  2146      this.forms.show(page.verifyForm)
  2147    }
  2148  
  2149    /*
  2150     * unlockWalletsForEstimates unlocks any locked wallets with the provided
  2151     * password.
  2152     */
  2153    async unlockWalletsForEstimates () {
  2154      const page = this.page
  2155      const loaded = app().loading(page.verifyForm)
  2156      await this.unlockMarketWallets()
  2157      loaded()
  2158      Doc.show(page.vPreorder)
  2159      this.preOrder(this.parseOrder())
  2160    }
  2161  
  2162    async unlockWallet (assetID: number) {
  2163      const res = await postJSON('/api/openwallet', { assetID })
  2164      if (!app().checkResponse(res)) {
  2165        throw Error('error unlocking wallet ' + res.msg)
  2166      }
  2167      this.balanceWgt.updateAsset(assetID)
  2168    }
  2169  
  2170    /*
  2171     * unlockMarketWallets unlocks both the base and quote wallets for the current
  2172     * market, if locked.
  2173     */
  2174    async unlockMarketWallets () {
  2175      const { base, quote } = this.market
  2176      const assetIDs = []
  2177      if (!base.wallet.open) assetIDs.push(base.id)
  2178      if (!quote.wallet.open) assetIDs.push(quote.id)
  2179      for (const assetID of assetIDs) {
  2180        this.unlockWallet(assetID)
  2181      }
  2182    }
  2183  
  2184    /* fetchPreorder fetches the pre-order estimates and options. */
  2185    async fetchPreorder (order: TradeForm) {
  2186      const page = this.page
  2187      const cacheKey = JSON.stringify(order.options)
  2188      const cached = this.preorderCache[cacheKey]
  2189      if (cached) return cached
  2190  
  2191      Doc.hide(page.vPreorderErr)
  2192      const loaded = app().loading(page.verifyForm)
  2193      const res = await postJSON('/api/preorder', wireOrder(order))
  2194      loaded()
  2195      if (!app().checkResponse(res)) return { err: res.msg }
  2196      this.preorderCache[cacheKey] = res.estimate
  2197      return res.estimate
  2198    }
  2199  
  2200    /*
  2201     * setPreorderErr sets and displays the pre-order error message and hides the
  2202     * pre-order details box.
  2203     */
  2204    setPreorderErr (msg: string) {
  2205      const page = this.page
  2206      Doc.hide(page.vPreorder)
  2207      Doc.show(page.vPreorderErr)
  2208      page.vPreorderErrTip.dataset.tooltip = msg
  2209    }
  2210  
  2211    showPreOrderAdvancedOptions () {
  2212      const page = this.page
  2213      Doc.hide(page.showAdvancedOptions)
  2214      Doc.show(page.hideAdvancedOptions, page.vOtherOrderOpts)
  2215    }
  2216  
  2217    hidePreOrderAdvancedOptions () {
  2218      const page = this.page
  2219      Doc.hide(page.hideAdvancedOptions, page.vOtherOrderOpts)
  2220      Doc.show(page.showAdvancedOptions)
  2221    }
  2222  
  2223    reloadOrderOpts (order: TradeForm, swap: PreSwap, redeem: PreRedeem, changed: ()=>void) {
  2224      const page = this.page
  2225      Doc.empty(page.vDefaultOrderOpts, page.vOtherOrderOpts)
  2226      const addOption = (opt: OrderOption, isSwap: boolean) => {
  2227        const el = OrderUtil.optionElement(opt, order, changed, isSwap)
  2228        if (opt.showByDefault) page.vDefaultOrderOpts.appendChild(el)
  2229        else page.vOtherOrderOpts.appendChild(el)
  2230      }
  2231      for (const opt of swap.options || []) addOption(opt, true)
  2232      for (const opt of redeem.options || []) addOption(opt, false)
  2233      app().bindTooltips(page.vDefaultOrderOpts)
  2234      app().bindTooltips(page.vOtherOrderOpts)
  2235    }
  2236  
  2237    /* preOrder loads the options and fetches pre-order estimates */
  2238    async preOrder (order: TradeForm) {
  2239      const page = this.page
  2240  
  2241      // Add swap options.
  2242      const refreshPreorder = async () => {
  2243        const res: APIResponse = await this.fetchPreorder(order)
  2244        if (res.err) return this.setPreorderErr(res.err)
  2245        const est = (res as any) as OrderEstimate
  2246        Doc.hide(page.vPreorderErr)
  2247        Doc.show(page.vPreorder)
  2248        const { swap, redeem } = est
  2249        swap.options = swap.options || []
  2250        redeem.options = redeem.options || []
  2251        this.setFeeEstimates(swap, redeem, order)
  2252  
  2253        const changed = async () => {
  2254          await refreshPreorder()
  2255          Doc.animate(400, progress => {
  2256            page.vFeeSummary.style.backgroundColor = `rgba(128, 128, 128, ${0.5 - 0.5 * progress})`
  2257          })
  2258        }
  2259        // bind show or hide advanced pre order options.
  2260        Doc.bind(page.showAdvancedOptions, 'click', () => { this.showPreOrderAdvancedOptions() })
  2261        Doc.bind(page.hideAdvancedOptions, 'click', () => { this.hidePreOrderAdvancedOptions() })
  2262        this.reloadOrderOpts(order, swap, redeem, changed)
  2263      }
  2264  
  2265      refreshPreorder()
  2266    }
  2267  
  2268    /* setFeeEstimates sets all of the pre-order estimate fields */
  2269    setFeeEstimates (swap: PreSwap, redeem: PreRedeem, order: TradeForm) {
  2270      const { page, market } = this
  2271      if (!swap.estimate || !redeem.estimate) {
  2272        Doc.hide(page.vPreorderEstimates)
  2273        return // preOrder may return just options, no fee estimates
  2274      }
  2275      Doc.show(page.vPreorderEstimates)
  2276      const { baseUnitInfo, quoteUnitInfo, rateConversionFactor } = market
  2277      const fmtPct = (value: number) => {
  2278        if (value < 0.05) return '< 0.1'
  2279        return percentFormatter.format(value)
  2280      }
  2281  
  2282      // If the asset is a token, in order to calculate the fee as a percentage
  2283      // of the total order, we try to  use the fiat rates to find out the
  2284      // exchange rate between the token and parent assets.
  2285      // Initially these are set to 1, which we would use if the asset is not a
  2286      // token and no conversion is needed.
  2287      let baseExchangeRate = 1
  2288      let quoteExchangeRate = 1
  2289      let baseFeeAssetUI = baseUnitInfo
  2290      let quoteFeeAssetUI = quoteUnitInfo
  2291  
  2292      if (market.base.token) {
  2293        const parent = app().assets[market.base.token.parentID]
  2294        baseFeeAssetUI = parent.unitInfo
  2295        const tokenFiatRate = app().fiatRatesMap[market.base.id]
  2296        const parentFiatRate = app().fiatRatesMap[parent.id]
  2297        if (tokenFiatRate && parentFiatRate) {
  2298          const conventionalRate = parentFiatRate / tokenFiatRate
  2299          baseExchangeRate = conventionalRate * baseUnitInfo.conventional.conversionFactor / parent.unitInfo.conventional.conversionFactor
  2300        } else {
  2301          baseExchangeRate = 0
  2302        }
  2303      }
  2304  
  2305      if (market.quote.token) {
  2306        const parent = app().assets[market.quote.token.parentID]
  2307        quoteFeeAssetUI = parent.unitInfo
  2308        const tokenFiatRate = app().fiatRatesMap[market.quote.id]
  2309        const parentFiatRate = app().fiatRatesMap[parent.id]
  2310        if (tokenFiatRate && parentFiatRate) {
  2311          const conventionalRate = parentFiatRate / tokenFiatRate
  2312          quoteExchangeRate = conventionalRate * quoteUnitInfo.conventional.conversionFactor / parent.unitInfo.conventional.conversionFactor
  2313        } else {
  2314          quoteExchangeRate = 0
  2315        }
  2316      }
  2317  
  2318      let [toFeeAssetUI, fromFeeAssetUI] = [baseFeeAssetUI, quoteFeeAssetUI]
  2319      let [toExchangeRate, fromExchangeRate] = [baseExchangeRate, quoteExchangeRate]
  2320      if (this.currentOrder.sell) {
  2321        [fromFeeAssetUI, toFeeAssetUI] = [toFeeAssetUI, fromFeeAssetUI];
  2322        [fromExchangeRate, toExchangeRate] = [toExchangeRate, fromExchangeRate]
  2323      }
  2324  
  2325      const swapped = swap.estimate.value || 0
  2326      const swappedInParentUnits = fromExchangeRate > 0 ? swapped / fromExchangeRate : swapped
  2327  
  2328      // Set swap fee estimates in the details pane.
  2329      const bestSwapPct = swap.estimate.realisticBestCase / swappedInParentUnits * 100
  2330      page.vSwapFeesLowPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(bestSwapPct)}%)`
  2331      page.vSwapFeesLow.textContent = Doc.formatCoinValue(swap.estimate.realisticBestCase, fromFeeAssetUI)
  2332  
  2333      const worstSwapPct = swap.estimate.realisticWorstCase / swappedInParentUnits * 100
  2334      page.vSwapFeesHighPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(worstSwapPct)}%)`
  2335      page.vSwapFeesHigh.textContent = Doc.formatCoinValue(swap.estimate.realisticWorstCase, fromFeeAssetUI)
  2336  
  2337      const swapFeesMaxPct = swap.estimate.maxFees / swappedInParentUnits * 100
  2338      page.vSwapFeesMaxPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(swapFeesMaxPct)}%)`
  2339      page.vSwapFeesMax.textContent = Doc.formatCoinValue(swap.estimate.maxFees, fromFeeAssetUI)
  2340  
  2341      // Set redemption fee estimates in the details pane.
  2342      const midGap = this.midGap()
  2343      const estRate = midGap || order.rate / rateConversionFactor
  2344      const received = order.sell ? swapped * estRate : swapped / estRate
  2345      const receivedInParentUnits = toExchangeRate > 0 ? received / toExchangeRate : received
  2346  
  2347      const bestRedeemPct = redeem.estimate.realisticBestCase / receivedInParentUnits * 100
  2348      page.vRedeemFeesLowPct.textContent = toExchangeRate <= 0 ? '' : `(${fmtPct(bestRedeemPct)}%)`
  2349      page.vRedeemFeesLow.textContent = Doc.formatCoinValue(redeem.estimate.realisticBestCase, toFeeAssetUI)
  2350  
  2351      const worstRedeemPct = redeem.estimate.realisticWorstCase / receivedInParentUnits * 100
  2352      page.vRedeemFeesHighPct.textContent = toExchangeRate <= 0 ? '' : `(${fmtPct(worstRedeemPct)}%)`
  2353      page.vRedeemFeesHigh.textContent = Doc.formatCoinValue(redeem.estimate.realisticWorstCase, toFeeAssetUI)
  2354  
  2355      if (baseExchangeRate && quoteExchangeRate) {
  2356        Doc.show(page.vFeeSummaryPct)
  2357        Doc.hide(page.vFeeSummary)
  2358        page.vFeeSummaryLow.textContent = fmtPct(bestSwapPct + bestRedeemPct)
  2359        page.vFeeSummaryHigh.textContent = fmtPct(worstSwapPct + worstRedeemPct)
  2360      } else {
  2361        Doc.hide(page.vFeeSummaryPct)
  2362        Doc.show(page.vFeeSummary)
  2363        page.summarySwapFeesLow.textContent = page.vSwapFeesLow.textContent
  2364        page.summarySwapFeesHigh.textContent = page.vSwapFeesHigh.textContent
  2365        page.summaryRedeemFeesLow.textContent = page.vRedeemFeesLow.textContent
  2366        page.summaryRedeemFeesHigh.textContent = page.vRedeemFeesHigh.textContent
  2367      }
  2368    }
  2369  
  2370    async submitCancel () {
  2371      // this will be the page.cancelSubmit button (evt.currentTarget)
  2372      const page = this.page
  2373      const cancelData = this.cancelData
  2374      const order = cancelData.order
  2375      const req = {
  2376        orderID: order.id
  2377      }
  2378      // Toggle the loader and submit button.
  2379      const loaded = app().loading(page.cancelSubmit)
  2380      const res = await postJSON('/api/cancel', req)
  2381      loaded()
  2382      // Display error on confirmation modal.
  2383      if (!app().checkResponse(res)) {
  2384        page.cancelErr.textContent = res.msg
  2385        Doc.show(page.cancelErr)
  2386        return
  2387      }
  2388      // Hide confirmation modal only on success.
  2389      Doc.hide(cancelData.bttn, page.forms)
  2390      order.cancelling = true
  2391    }
  2392  
  2393    /* showCancel shows a form to confirm submission of a cancel order. */
  2394    showCancel (row: HTMLElement, orderID: string) {
  2395      const ord = this.metaOrders[orderID].ord
  2396      const page = this.page
  2397      const remaining = ord.qty - ord.filled
  2398      const asset = OrderUtil.isMarketBuy(ord) ? this.market.quote : this.market.base
  2399      page.cancelRemain.textContent = Doc.formatCoinValue(remaining, asset.unitInfo)
  2400      page.cancelUnit.textContent = asset.symbol.toUpperCase()
  2401      Doc.hide(page.cancelErr)
  2402      this.forms.show(page.cancelForm)
  2403      this.cancelData = {
  2404        bttn: Doc.tmplElement(row, 'cancelBttn'),
  2405        order: ord
  2406      }
  2407    }
  2408  
  2409    /* showAccelerate shows the accelerate order form. */
  2410    showAccelerate (order: Order) {
  2411      const loaded = app().loading(this.main)
  2412      this.accelerateOrderForm.refresh(order)
  2413      loaded()
  2414      this.forms.show(this.page.accelerateForm)
  2415    }
  2416  
  2417    /* showCreate shows the new wallet creation form. */
  2418    showCreate (asset: SupportedAsset) {
  2419      const page = this.page
  2420      this.currentCreate = asset
  2421      this.newWalletForm.setAsset(asset.id)
  2422      this.forms.show(page.newWalletForm)
  2423    }
  2424  
  2425    /*
  2426     * stepSubmit will examine the current state of wallets and step the user
  2427     * through the process of order submission.
  2428     * NOTE: I expect this process will be streamlined soon such that the wallets
  2429     * will attempt to be unlocked in the order submission process, negating the
  2430     * need to unlock ahead of time.
  2431     */
  2432    stepSubmit () {
  2433      const page = this.page
  2434      const market = this.market
  2435      Doc.hide(page.orderErr)
  2436      if (!this.validateOrder(this.parseOrder())) return
  2437      const baseWallet = app().walletMap[market.base.id]
  2438      const quoteWallet = app().walletMap[market.quote.id]
  2439      if (!baseWallet) {
  2440        page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.base.symbol })
  2441        Doc.show(page.orderErr)
  2442        return
  2443      }
  2444      if (!quoteWallet) {
  2445        page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.quote.symbol })
  2446        Doc.show(page.orderErr)
  2447        return
  2448      }
  2449      this.showVerify()
  2450    }
  2451  
  2452    /* Display a deposit address. */
  2453    async showDeposit (assetID: number) {
  2454      this.depositAddrForm.setAsset(assetID)
  2455      this.forms.show(this.page.deposit)
  2456    }
  2457  
  2458    showCustomProviderDialog (assetID: number) {
  2459      app().loadPage('wallets', { promptProvider: assetID, goBack: 'markets' })
  2460    }
  2461  
  2462    /*
  2463     * handlePriceUpdate is the handler for the 'spots' notification.
  2464     */
  2465    handlePriceUpdate (note: SpotPriceNote) {
  2466      if (!this.market) return // This note can arrive before the market is set.
  2467      if (note.host === this.market.dex.host && note.spots[this.market.cfg.name]) {
  2468        this.setCurrMarketPrice()
  2469      }
  2470      this.marketList.updateSpots(note)
  2471    }
  2472  
  2473    async handleWalletState (note: WalletStateNote) {
  2474      if (!this.market) return // This note can arrive before the market is set.
  2475      // if (note.topic !== 'TokenApproval') return
  2476      if (note.wallet.assetID !== this.market.base?.id && note.wallet.assetID !== this.market.quote?.id) return
  2477      this.setTokenApprovalVisibility()
  2478      this.resolveOrderFormVisibility()
  2479    }
  2480  
  2481    /*
  2482     * handleBondUpdate is the handler for the 'bondpost' notification type.
  2483     * This is used to update the registration status of the current exchange.
  2484     */
  2485    async handleBondUpdate (note: BondNote) {
  2486      const dexAddr = note.dex
  2487      if (!this.market) return // This note can arrive before the market is set.
  2488      if (dexAddr !== this.market.dex.host) return
  2489      // If we just finished legacy registration, we need to update the Exchange.
  2490      // TODO: Use tier change notification once available.
  2491      if (note.topic === 'AccountRegistered') await app().fetchUser()
  2492      // Update local copy of Exchange.
  2493      this.market.dex = app().exchanges[dexAddr]
  2494      this.setRegistrationStatusVisibility()
  2495      this.updateReputation()
  2496    }
  2497  
  2498    updateReputation () {
  2499      const { page, market: { dex: { host }, cfg: mkt, baseCfg: { unitInfo: bui }, quoteCfg: { unitInfo: qui } } } = this
  2500      const { auth } = app().exchanges[host]
  2501  
  2502      page.parcelSizeLots.textContent = String(mkt.parcelsize)
  2503      page.marketLimitBase.textContent = Doc.formatFourSigFigs(mkt.parcelsize * mkt.lotsize / bui.conventional.conversionFactor)
  2504      page.marketLimitBaseUnit.textContent = bui.conventional.unit
  2505      page.marketLimitQuoteUnit.textContent = qui.conventional.unit
  2506      const conversionRate = this.anyRate()[1]
  2507      if (conversionRate) {
  2508        const quoteLot = mkt.lotsize * conversionRate
  2509        page.marketLimitQuote.textContent = Doc.formatFourSigFigs(mkt.parcelsize * quoteLot / qui.conventional.conversionFactor)
  2510      } else page.marketLimitQuote.textContent = '-'
  2511  
  2512      const tier = strongTier(auth)
  2513      page.tradingTier.textContent = String(tier)
  2514      const [usedParcels, parcelLimit] = tradingLimits(host)
  2515      page.tradingLimit.textContent = (parcelLimit * mkt.parcelsize).toFixed(2)
  2516      page.limitUsage.textContent = parcelLimit > 0 ? (usedParcels / parcelLimit * 100).toFixed(1) : '0'
  2517  
  2518      page.orderLimitRemain.textContent = ((parcelLimit - usedParcels) * mkt.parcelsize).toFixed(1)
  2519      page.orderTradingTier.textContent = String(tier)
  2520  
  2521      this.reputationMeter.update()
  2522    }
  2523  
  2524    /*
  2525     * anyRate finds the best rate from any of, in order of priority, the order
  2526     * book, the server's reported spot rate, or the fiat exchange rates. A
  2527     * 3-tuple of message-rate encoding, a conversion rate, and a conventional
  2528     * rate is generated.
  2529     */
  2530    anyRate (): [number, number, number] {
  2531      const { cfg: { spot }, baseCfg: { id: baseID }, quoteCfg: { id: quoteID }, rateConversionFactor, bookLoaded } = this.market
  2532      if (bookLoaded) {
  2533        const midGap = this.midGap()
  2534        if (midGap) return [midGap * OrderUtil.RateEncodingFactor, midGap, this.midGapConventional() || 0]
  2535      }
  2536      if (spot && spot.rate) return [spot.rate, spot.rate / OrderUtil.RateEncodingFactor, spot.rate / rateConversionFactor]
  2537      const [baseUSD, quoteUSD] = [app().fiatRatesMap[baseID], app().fiatRatesMap[quoteID]]
  2538      if (baseUSD && quoteUSD) {
  2539        const conventionalRate = baseUSD / quoteUSD
  2540        const msgRate = conventionalRate * rateConversionFactor
  2541        const conversionRate = msgRate / OrderUtil.RateEncodingFactor
  2542        return [msgRate, conversionRate, conventionalRate]
  2543      }
  2544      return [0, 0, 0]
  2545    }
  2546  
  2547    handleMatchNote (note: MatchNote) {
  2548      const mord = this.metaOrders[note.orderID]
  2549      const match = note.match
  2550      if (!mord) return this.refreshActiveOrders()
  2551      else if (mord.ord.type === OrderUtil.Market && match.status === OrderUtil.NewlyMatched) { // Update the average market rate display.
  2552        // Fetch and use the updated order.
  2553        const ord = app().order(note.orderID)
  2554        if (ord) mord.details.rate.textContent = mord.header.rate.textContent = this.marketOrderRateString(ord, this.market)
  2555      }
  2556      if (
  2557        (match.side === OrderUtil.MatchSideMaker && match.status === OrderUtil.MakerRedeemed) ||
  2558        (match.side === OrderUtil.MatchSideTaker && match.status === OrderUtil.MatchComplete)
  2559      ) this.updateReputation()
  2560      if (app().canAccelerateOrder(mord.ord)) Doc.show(mord.details.accelerateBttn)
  2561      else Doc.hide(mord.details.accelerateBttn)
  2562    }
  2563  
  2564    /*
  2565     * handleOrderNote is the handler for the 'order'-type notification, which are
  2566     * used to update a user's order's status.
  2567     */
  2568    handleOrderNote (note: OrderNote) {
  2569      const ord = note.order
  2570      const mord = this.metaOrders[ord.id]
  2571      // - If metaOrder doesn't exist for the given order it means it was created
  2572      //  via bwctl and the GUI isn't aware of it or it was an inflight order.
  2573      //  refreshActiveOrders must be called to grab this order.
  2574      // - If an OrderLoaded notification is recieved, it means an order that was
  2575      //   previously not "ready to tick" (due to its wallets not being connected
  2576      //   and unlocked) has now become ready to tick. The active orders section
  2577      //   needs to be refreshed.
  2578      const wasInflight = note.topic === 'AsyncOrderFailure' || note.topic === 'AsyncOrderSubmitted'
  2579      if (!mord || wasInflight || (note.topic === 'OrderLoaded' && ord.readyToTick)) {
  2580        return this.refreshActiveOrders()
  2581      }
  2582      const oldStatus = mord.ord.status
  2583      mord.ord = ord
  2584      if (note.topic === 'MissedCancel') Doc.show(mord.details.cancelBttn)
  2585      if (ord.filled === ord.qty) Doc.hide(mord.details.cancelBttn)
  2586      if (app().canAccelerateOrder(ord)) Doc.show(mord.details.accelerateBttn)
  2587      else Doc.hide(mord.details.accelerateBttn)
  2588      this.updateMetaOrder(mord)
  2589      // Only reset markers if there is a change, since the chart is redrawn.
  2590      if (
  2591        (oldStatus === OrderUtil.StatusEpoch && ord.status === OrderUtil.StatusBooked) ||
  2592        (oldStatus === OrderUtil.StatusBooked && ord.status > OrderUtil.StatusBooked)
  2593      ) {
  2594        this.setDepthMarkers()
  2595        this.updateReputation()
  2596        this.mm.readBook()
  2597      }
  2598    }
  2599  
  2600    /*
  2601     * handleEpochNote handles notifications signalling the start of a new epoch.
  2602     */
  2603    handleEpochNote (note: EpochNote) {
  2604      app().log('book', 'handleEpochNote:', note)
  2605      if (!this.market) return // This note can arrive before the market is set.
  2606      if (note.host !== this.market.dex.host || note.marketID !== this.market.sid) return
  2607      if (this.book) {
  2608        this.book.setEpoch(note.epoch)
  2609        this.depthChart.draw()
  2610      }
  2611  
  2612      this.clearOrderTableEpochs()
  2613      for (const { ord, details, header } of Object.values(this.metaOrders)) {
  2614        const alreadyMatched = note.epoch > ord.epoch
  2615        switch (true) {
  2616          case ord.type === OrderUtil.Limit && ord.status === OrderUtil.StatusEpoch && alreadyMatched: {
  2617            const status = ord.tif === OrderUtil.ImmediateTiF ? intl.prep(intl.ID_EXECUTED) : intl.prep(intl.ID_BOOKED)
  2618            details.status.textContent = header.status.textContent = status
  2619            ord.status = ord.tif === OrderUtil.ImmediateTiF ? OrderUtil.StatusExecuted : OrderUtil.StatusBooked
  2620            break
  2621          }
  2622          case ord.type === OrderUtil.Market && ord.status === OrderUtil.StatusEpoch:
  2623            // Technically don't know if this should be 'executed' or 'settling'.
  2624            details.status.textContent = header.status.textContent = intl.prep(intl.ID_EXECUTED)
  2625            ord.status = OrderUtil.StatusExecuted
  2626            break
  2627        }
  2628      }
  2629    }
  2630  
  2631    /*
  2632     * recentMatchesSortCompare returns sort compare function according to the active
  2633     * sort key and direction.
  2634     */
  2635    recentMatchesSortCompare () {
  2636      switch (this.recentMatchesSortKey) {
  2637        case 'rate':
  2638          return (a: RecentMatch, b: RecentMatch) => this.recentMatchesSortDirection * (a.rate - b.rate)
  2639        case 'qty':
  2640          return (a: RecentMatch, b: RecentMatch) => this.recentMatchesSortDirection * (a.qty - b.qty)
  2641        case 'age':
  2642          return (a: RecentMatch, b:RecentMatch) => this.recentMatchesSortDirection * (a.stamp - b.stamp)
  2643      }
  2644    }
  2645  
  2646    refreshRecentMatchesTable () {
  2647      const page = this.page
  2648      const recentMatches = this.recentMatches
  2649      Doc.empty(page.recentMatchesLiveList)
  2650      if (!recentMatches) return
  2651      const compare = this.recentMatchesSortCompare()
  2652      recentMatches.sort(compare)
  2653      for (const match of recentMatches) {
  2654        const row = page.recentMatchesTemplate.cloneNode(true) as HTMLElement
  2655        const tmpl = Doc.parseTemplate(row)
  2656        app().bindTooltips(row)
  2657        tmpl.rate.textContent = Doc.formatCoinValue(match.rate / this.market.rateConversionFactor)
  2658        tmpl.qty.textContent = Doc.formatCoinValue(match.qty, this.market.baseUnitInfo)
  2659        tmpl.age.textContent = Doc.timeSince(match.stamp)
  2660        tmpl.age.dataset.sinceStamp = String(match.stamp)
  2661        row.classList.add(match.sell ? 'sellcolor' : 'buycolor')
  2662        page.recentMatchesLiveList.append(row)
  2663      }
  2664    }
  2665  
  2666    addRecentMatches (matches: RecentMatch[]) {
  2667      this.recentMatches = [...matches, ...this.recentMatches].slice(0, 100)
  2668    }
  2669  
  2670    /* handleBalanceNote handles notifications updating a wallet's balance. */
  2671    handleBalanceNote (note: BalanceNote) {
  2672      this.approveTokenForm.handleBalanceNote(note)
  2673      this.preorderCache = {} // invalidate previous preorder results
  2674      // if connection to dex server fails, it is not possible to retrieve
  2675      // markets.
  2676      const mkt = this.market
  2677      if (!mkt || !mkt.dex || mkt.dex.connectionStatus !== ConnectionStatus.Connected) return
  2678  
  2679      this.mm.handleBalanceNote(note)
  2680      const wgt = this.balanceWgt
  2681      // Display the widget if the balance note is for its base or quote wallet.
  2682      if ((note.assetID === wgt.base.id || note.assetID === wgt.quote.id)) wgt.setBalanceVisibility(true)
  2683  
  2684      // If there's a balance update, refresh the max order section.
  2685      const avail = note.balance.available
  2686      switch (note.assetID) {
  2687        case mkt.baseCfg.id:
  2688          // If we're not showing the max order panel yet, don't do anything.
  2689          if (!mkt.maxSell) break
  2690          if (typeof mkt.sellBalance === 'number' && mkt.sellBalance !== avail) mkt.maxSell = null
  2691          if (this.isSell()) this.preSell()
  2692          break
  2693        case mkt.quoteCfg.id:
  2694          if (!Object.keys(mkt.maxBuys).length) break
  2695          if (typeof mkt.buyBalance === 'number' && mkt.buyBalance !== avail) mkt.maxBuys = {}
  2696          if (!this.isSell()) this.preBuy()
  2697      }
  2698    }
  2699  
  2700    /*
  2701     * submitOrder is attached to the affirmative button on the order validation
  2702     * form. Clicking the button is the last step in the order submission process.
  2703     */
  2704    async submitOrder () {
  2705      const page = this.page
  2706      Doc.hide(page.orderErr, page.vErr)
  2707      const order = this.currentOrder
  2708      const req = { order: wireOrder(order) }
  2709      if (!this.validateOrder(order)) return
  2710      // Show loader and hide submit button.
  2711      page.vSubmit.classList.add('d-hide')
  2712      page.vLoader.classList.remove('d-hide')
  2713      const res = await postJSON('/api/tradeasync', req)
  2714      // Hide loader and show submit button.
  2715      page.vSubmit.classList.remove('d-hide')
  2716      page.vLoader.classList.add('d-hide')
  2717      // If error, display error on confirmation modal.
  2718      if (!app().checkResponse(res)) {
  2719        page.vErr.textContent = res.msg
  2720        Doc.show(page.vErr)
  2721        return
  2722      }
  2723      // Hide confirmation modal only on success.
  2724      Doc.hide(page.forms)
  2725      this.refreshActiveOrders()
  2726    }
  2727  
  2728    /*
  2729     * createWallet is attached to successful submission of the wallet creation
  2730     * form. createWallet is only called once the form is submitted and a success
  2731     * response is received from the client.
  2732     */
  2733    async createWallet () {
  2734      const user = await app().fetchUser()
  2735      if (!user) return
  2736      const asset = user.assets[this.currentCreate.id]
  2737      Doc.hide(this.page.forms)
  2738      const mkt = this.market
  2739      if (mkt.baseCfg.id === asset.id) mkt.base = asset
  2740      else if (mkt.quoteCfg.id === asset.id) mkt.quote = asset
  2741      this.balanceWgt.updateAsset(asset.id)
  2742      this.displayMessageIfMissingWallet()
  2743      this.resolveOrderFormVisibility()
  2744    }
  2745  
  2746    /* lotChanged is attached to the keyup and change events of the lots input. */
  2747    lotChanged () {
  2748      const page = this.page
  2749      const lots = parseInt(page.lotField.value || '0')
  2750      if (lots <= 0) {
  2751        page.lotField.value = page.lotField.value === '' ? '' : '0'
  2752        page.qtyField.value = ''
  2753        this.previewQuoteAmt(false)
  2754        this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR))
  2755        return
  2756      }
  2757      const lotSize = this.market.cfg.lotsize
  2758      const orderQty = lots * lotSize
  2759      page.lotField.value = String(lots)
  2760      // Conversion factor must be a multiple of 10.
  2761      page.qtyField.value = String(orderQty / this.market.baseUnitInfo.conventional.conversionFactor)
  2762  
  2763      if (!this.isLimit() && this.isSell()) {
  2764        const baseWallet = app().assets[this.market.base.id].wallet
  2765        this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR))
  2766      }
  2767      this.previewQuoteAmt(true)
  2768    }
  2769  
  2770    /*
  2771     * quantityChanged is attached to the keyup and change events of the quantity
  2772     * input.
  2773     */
  2774    quantityChanged (finalize: boolean) {
  2775      const page = this.page
  2776      const order = this.currentOrder = this.parseOrder()
  2777      if (order.qty < 0) {
  2778        page.lotField.value = '0'
  2779        page.qtyField.value = ''
  2780        this.previewQuoteAmt(false)
  2781        return
  2782      }
  2783      const lotSize = this.market.cfg.lotsize
  2784      const lots = Math.floor(order.qty / lotSize)
  2785      const adjusted = order.qty = this.currentOrder.qty = lots * lotSize
  2786      page.lotField.value = String(lots)
  2787  
  2788      if (!order.isLimit && !order.sell) return
  2789  
  2790      // Conversion factor must be a multiple of 10.
  2791      if (finalize) page.qtyField.value = String(adjusted / this.market.baseUnitInfo.conventional.conversionFactor)
  2792      this.previewQuoteAmt(true)
  2793    }
  2794  
  2795    /*
  2796     * marketBuyChanged is attached to the keyup and change events of the quantity
  2797     * input for the market-buy form.
  2798     */
  2799    marketBuyChanged () {
  2800      const page = this.page
  2801      const qty = convertToAtoms(page.mktBuyField.value || '', this.market.quoteUnitInfo.conventional.conversionFactor)
  2802      const gap = this.midGap()
  2803      if (qty > 0) {
  2804        const quoteWallet = app().assets[this.market.quote.id].wallet
  2805        this.setOrderBttnEnabled(qty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR))
  2806      } else {
  2807        this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR))
  2808      }
  2809      if (!gap || !qty) {
  2810        page.mktBuyLots.textContent = '0'
  2811        page.mktBuyScore.textContent = '0'
  2812        return
  2813      }
  2814      const lotSize = this.market.cfg.lotsize
  2815      const received = qty / gap
  2816      const lots = (received / lotSize)
  2817      page.mktBuyLots.textContent = lots.toFixed(1)
  2818      page.mktBuyScore.textContent = Doc.formatCoinValue(received, this.market.baseUnitInfo)
  2819    }
  2820  
  2821    /*
  2822     * rateFieldChanged is attached to the keyup and change events of the rate
  2823     * input.
  2824     */
  2825    rateFieldChanged () {
  2826      // Truncate to rate step. If it is a market buy order, do not adjust.
  2827      const adjusted = this.adjustedRate()
  2828      if (adjusted <= 0) {
  2829        this.depthLines.input = []
  2830        this.drawChartLines()
  2831        this.page.rateField.value = '0'
  2832        this.previewQuoteAmt(true)
  2833        this.updateOrderBttnState()
  2834        return
  2835      }
  2836      const order = this.currentOrder = this.parseOrder()
  2837      const r = adjusted / this.market.rateConversionFactor
  2838      this.page.rateField.value = String(r)
  2839      this.depthLines.input = [{
  2840        rate: r,
  2841        color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine
  2842      }]
  2843      this.drawChartLines()
  2844      this.previewQuoteAmt(true)
  2845      this.updateOrderBttnState()
  2846    }
  2847  
  2848    /*
  2849     * adjustedRate is the current rate field rate, rounded down to a
  2850     * multiple of rateStep.
  2851     */
  2852    adjustedRate (): number {
  2853      const v = this.page.rateField.value
  2854      if (!v) return NaN
  2855      const rate = convertToAtoms(v, this.market.rateConversionFactor)
  2856      const rateStep = this.market.cfg.ratestep
  2857      return rate - (rate % rateStep)
  2858    }
  2859  
  2860    /* loadTable reloads the table from the current order book information. */
  2861    loadTable () {
  2862      this.loadTableSide(true)
  2863      this.loadTableSide(false)
  2864    }
  2865  
  2866    /* binOrdersByRateAndEpoch takes a list of sorted orders and returns the
  2867       same orders grouped into arrays. The orders are grouped by their rate
  2868       and whether or not they are epoch queue orders. Epoch queue orders
  2869       will come after non epoch queue orders with the same rate. */
  2870    binOrdersByRateAndEpoch (orders: MiniOrder[]) {
  2871      if (!orders || !orders.length) return []
  2872      const bins = []
  2873      let currEpochBin = []
  2874      let currNonEpochBin = []
  2875      let currRate = orders[0].msgRate
  2876      if (orders[0].epoch) currEpochBin.push(orders[0])
  2877      else currNonEpochBin.push(orders[0])
  2878      for (let i = 1; i < orders.length; i++) {
  2879        if (orders[i].msgRate !== currRate) {
  2880          bins.push(currNonEpochBin)
  2881          bins.push(currEpochBin)
  2882          currEpochBin = []
  2883          currNonEpochBin = []
  2884          currRate = orders[i].msgRate
  2885        }
  2886        if (orders[i].epoch) currEpochBin.push(orders[i])
  2887        else currNonEpochBin.push(orders[i])
  2888      }
  2889      bins.push(currNonEpochBin)
  2890      bins.push(currEpochBin)
  2891      return bins.filter(bin => bin.length > 0)
  2892    }
  2893  
  2894    /* loadTables loads the order book side into its table. */
  2895    loadTableSide (sell: boolean) {
  2896      const bookSide = sell ? this.book.sells : this.book.buys
  2897      const tbody = sell ? this.page.sellRows : this.page.buyRows
  2898      Doc.empty(tbody)
  2899      if (!bookSide || !bookSide.length) return
  2900      const orderBins = this.binOrdersByRateAndEpoch(bookSide)
  2901      orderBins.forEach(bin => { tbody.appendChild(this.orderTableRow(bin)) })
  2902    }
  2903  
  2904    /* addTableOrder adds a single order to the appropriate table. */
  2905    addTableOrder (order: MiniOrder) {
  2906      const tbody = order.sell ? this.page.sellRows : this.page.buyRows
  2907      let row = tbody.firstChild as OrderRow
  2908      // Handle market order differently.
  2909      if (order.rate === 0) {
  2910        if (order.qtyAtomic === 0) return // a cancel order. TODO: maybe make an indicator on the target order, maybe gray out
  2911        // This is a market order.
  2912        if (row && row.manager.getRate() === 0) {
  2913          row.manager.insertOrder(order)
  2914        } else {
  2915          row = this.orderTableRow([order])
  2916          tbody.insertBefore(row, tbody.firstChild)
  2917        }
  2918        return
  2919      }
  2920      // Must be a limit order. Sort by rate. Skip the market order row.
  2921      if (row && row.manager.getRate() === 0) row = row.nextSibling as OrderRow
  2922      while (row) {
  2923        if (row.manager.compare(order) === 0) {
  2924          row.manager.insertOrder(order)
  2925          return
  2926        } else if (row.manager.compare(order) > 0) {
  2927          const tr = this.orderTableRow([order])
  2928          tbody.insertBefore(tr, row)
  2929          return
  2930        }
  2931        row = row.nextSibling as OrderRow
  2932      }
  2933      const tr = this.orderTableRow([order])
  2934      tbody.appendChild(tr)
  2935    }
  2936  
  2937    /* removeTableOrder removes a single order from its table. */
  2938    removeTableOrder (order: MiniOrder) {
  2939      const token = order.token
  2940      for (const tbody of [this.page.sellRows, this.page.buyRows]) {
  2941        for (const tr of (Array.from(tbody.children) as OrderRow[])) {
  2942          if (tr.manager.removeOrder(token)) {
  2943            return
  2944          }
  2945        }
  2946      }
  2947    }
  2948  
  2949    /* updateTableOrder looks for the order in the table and updates the qty */
  2950    updateTableOrder (u: RemainderUpdate) {
  2951      for (const tbody of [this.page.sellRows, this.page.buyRows]) {
  2952        for (const tr of (Array.from(tbody.children) as OrderRow[])) {
  2953          if (tr.manager.updateOrderQty(u)) {
  2954            return
  2955          }
  2956        }
  2957      }
  2958    }
  2959  
  2960    /*
  2961     * clearOrderTableEpochs removes immediate-tif orders whose epoch has expired.
  2962     */
  2963    clearOrderTableEpochs () {
  2964      this.clearOrderTableEpochSide(this.page.sellRows)
  2965      this.clearOrderTableEpochSide(this.page.buyRows)
  2966    }
  2967  
  2968    /*
  2969     * clearOrderTableEpochs removes immediate-tif orders whose epoch has expired
  2970     * for a single side.
  2971     */
  2972    clearOrderTableEpochSide (tbody: HTMLElement) {
  2973      for (const tr of (Array.from(tbody.children)) as OrderRow[]) {
  2974        tr.manager.removeEpochOrders()
  2975      }
  2976    }
  2977  
  2978    /*
  2979     * orderTableRow creates a new <tr> element to insert into an order table.
  2980       Takes a bin of orders with the same rate, and displays the total quantity.
  2981     */
  2982    orderTableRow (orderBin: MiniOrder[]): OrderRow {
  2983      const tr = this.page.orderRowTmpl.cloneNode(true) as OrderRow
  2984      const { baseUnitInfo, quoteUnitInfo, rateConversionFactor, cfg: { ratestep: rateStep } } = this.market
  2985      const manager = new OrderTableRowManager(tr, orderBin, baseUnitInfo, quoteUnitInfo, rateStep)
  2986      tr.manager = manager
  2987      bind(tr, 'click', () => {
  2988        this.reportDepthClick(tr.manager.getRate() / rateConversionFactor)
  2989      })
  2990      if (tr.manager.getRate() !== 0) {
  2991        Doc.bind(tr, 'mouseenter', () => {
  2992          const chart = this.depthChart
  2993          this.depthLines.hover = [{
  2994            rate: tr.manager.getRate() / rateConversionFactor,
  2995            color: tr.manager.isSell() ? chart.theme.sellLine : chart.theme.buyLine
  2996          }]
  2997          this.drawChartLines()
  2998        })
  2999      }
  3000      return tr
  3001    }
  3002  
  3003    /* handleConnNote handles the 'conn' notification.
  3004     */
  3005    async handleConnNote (note: ConnEventNote) {
  3006      this.marketList.setConnectionStatus(note)
  3007      if (note.connectionStatus === ConnectionStatus.Connected) {
  3008        // Having been disconnected from a DEX server, anything may have changed,
  3009        // or this may be the first opportunity to get the server's config, so
  3010        // fetch it all before reloading the markets page.
  3011        await app().fetchUser()
  3012        await app().loadPage('markets')
  3013      }
  3014    }
  3015  
  3016    /*
  3017     * filterMarkets sets the display of markets in the markets list based on the
  3018     * value of the search input.
  3019     */
  3020    filterMarkets () {
  3021      const filterTxt = this.page.marketSearchV1.value?.toLowerCase()
  3022      const filter = filterTxt ? (mkt: MarketRow) => mkt.name.includes(filterTxt) : () => true
  3023      this.marketList.setFilter(filter)
  3024    }
  3025  
  3026    /* drawChartLines draws the hover and input lines on the chart. */
  3027    drawChartLines () {
  3028      this.depthChart.setLines([...this.depthLines.hover, ...this.depthLines.input])
  3029      this.depthChart.draw()
  3030    }
  3031  
  3032    /* candleDurationSelected sets the candleDur and loads the candles. It will
  3033    default to the oneHrBinKey if dur is not valid. */
  3034    candleDurationSelected (dur: string) {
  3035      if (!this.market?.dex?.candleDurs.includes(dur)) dur = oneHrBinKey
  3036      this.candleDur = dur
  3037      this.loadCandles()
  3038      State.storeLocal(State.lastCandleDurationLK, dur)
  3039    }
  3040  
  3041    /*
  3042     * loadCandles loads the candles for the current candleDur. If a cache is already
  3043     * active, the cache will be used without a loadcandles request.
  3044     */
  3045    loadCandles () {
  3046      for (const bttn of Doc.kids(this.page.durBttnBox)) {
  3047        if (bttn.textContent === this.candleDur) bttn.classList.add('selected')
  3048        else bttn.classList.remove('selected')
  3049      }
  3050      const { candleCaches, cfg, baseUnitInfo, quoteUnitInfo } = this.market
  3051      const cache = candleCaches[this.candleDur]
  3052      if (cache) {
  3053        // this.depthChart.hide()
  3054        // this.candleChart.show()
  3055        this.candleChart.setCandles(cache, cfg, baseUnitInfo, quoteUnitInfo)
  3056        return
  3057      }
  3058      this.requestCandles()
  3059    }
  3060  
  3061    /* requestCandles sends the loadcandles request. It accepts an optional candle
  3062     * duration which will be requested if it is provided.
  3063     */
  3064    requestCandles (candleDur?: string) {
  3065      this.candlesLoading = {
  3066        loaded: () => { /* pass */ },
  3067        timer: window.setTimeout(() => {
  3068          if (this.candlesLoading) {
  3069            this.candlesLoading = null
  3070            console.error('candles not received')
  3071          }
  3072        }, 10000)
  3073      }
  3074      const { dex, baseCfg, quoteCfg } = this.market
  3075      ws.request('loadcandles', { host: dex.host, base: baseCfg.id, quote: quoteCfg.id, dur: candleDur || this.candleDur })
  3076    }
  3077  
  3078    /*
  3079     * unload is called by the Application when the user navigates away from
  3080     * the /markets page.
  3081     */
  3082    unload () {
  3083      ws.request(unmarketRoute, {})
  3084      ws.deregisterRoute(bookRoute)
  3085      ws.deregisterRoute(bookOrderRoute)
  3086      ws.deregisterRoute(unbookOrderRoute)
  3087      ws.deregisterRoute(updateRemainingRoute)
  3088      ws.deregisterRoute(epochOrderRoute)
  3089      ws.deregisterRoute(candlesRoute)
  3090      ws.deregisterRoute(candleUpdateRoute)
  3091      this.depthChart.unattach()
  3092      this.candleChart.unattach()
  3093      Doc.unbind(document, 'keyup', this.keyup)
  3094      clearInterval(this.secondTicker)
  3095    }
  3096  }
  3097  
  3098  /*
  3099   *  MarketList represents the list of exchanges and markets on the left side of
  3100   * markets view. The MarketList provides utilities for adjusting the visibility
  3101   * and sort order of markets.
  3102   */
  3103  class MarketList {
  3104    // xcSections: ExchangeSection[]
  3105    div: PageElement
  3106    rowTmpl: PageElement
  3107    markets: MarketRow[]
  3108    selected: MarketRow
  3109  
  3110    constructor (div: HTMLElement) {
  3111      this.div = div
  3112      this.rowTmpl = Doc.idel(div, 'marketTmplV1')
  3113      Doc.cleanTemplates(this.rowTmpl)
  3114      this.reloadMarketsPane()
  3115    }
  3116  
  3117    updateSpots (note: SpotPriceNote) {
  3118      for (const row of this.markets) {
  3119        if (row.mkt.xc.host !== note.host) continue
  3120        const xc = app().exchanges[row.mkt.xc.host]
  3121        const mkt = xc.markets[row.mkt.name]
  3122        setPriceAndChange(row.tmpl, xc, mkt)
  3123      }
  3124    }
  3125  
  3126    reloadMarketsPane (): void {
  3127      Doc.empty(this.div)
  3128      this.markets = []
  3129  
  3130      const addMarket = (mkt: ExchangeMarket) => {
  3131        const bui = app().unitInfo(mkt.baseid, mkt.xc)
  3132        const qui = app().unitInfo(mkt.quoteid, mkt.xc)
  3133        const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor
  3134        const row = new MarketRow(this.rowTmpl, mkt, rateConversionFactor)
  3135        this.div.appendChild(row.node)
  3136        return row
  3137      }
  3138  
  3139      for (const mkt of sortedMarkets()) this.markets.push(addMarket(mkt))
  3140      app().bindTooltips(this.div)
  3141    }
  3142  
  3143    find (host: string, baseID: number, quoteID: number): MarketRow | null {
  3144      for (const row of this.markets) {
  3145        if (row.mkt.xc.host === host && row.mkt.baseid === baseID && row.mkt.quoteid === quoteID) return row
  3146      }
  3147      return null
  3148    }
  3149  
  3150    /* exists will be true if the specified market exists. */
  3151    exists (host: string, baseID: number, quoteID: number): boolean {
  3152      return this.find(host, baseID, quoteID) !== null
  3153    }
  3154  
  3155    /* first gets the first market from the first exchange, alphabetically. */
  3156    first (): MarketRow {
  3157      return this.markets[0]
  3158    }
  3159  
  3160    /* select sets the specified market as selected. */
  3161    select (host: string, baseID: number, quoteID: number) {
  3162      const row = this.find(host, baseID, quoteID)
  3163      if (!row) return console.error(`select: no market row for ${host}, ${baseID}-${quoteID}`)
  3164      for (const mkt of this.markets) mkt.node.classList.remove('selected')
  3165      this.selected = row
  3166      this.selected.node.classList.add('selected')
  3167    }
  3168  
  3169    /* setConnectionStatus sets the visibility of the disconnected icon based
  3170     * on the core.ConnEventNote.
  3171     */
  3172    setConnectionStatus (note: ConnEventNote) {
  3173      for (const row of this.markets) {
  3174        if (row.mkt.xc.host !== note.host) continue
  3175        if (note.connectionStatus === ConnectionStatus.Connected) Doc.hide(row.tmpl.disconnectedIco)
  3176        else Doc.show(row.tmpl.disconnectedIco)
  3177      }
  3178    }
  3179  
  3180    /*
  3181     * setFilter sets the visibility of market rows based on the provided filter.
  3182     */
  3183    setFilter (filter: (mkt: MarketRow) => boolean) {
  3184      for (const row of this.markets) {
  3185        if (filter(row)) Doc.show(row.node)
  3186        else Doc.hide(row.node)
  3187      }
  3188    }
  3189  }
  3190  
  3191  /*
  3192   * MarketRow represents one row in the MarketList. A MarketRow is a subsection
  3193   * of the ExchangeSection.
  3194   */
  3195  class MarketRow {
  3196    node: HTMLElement
  3197    mkt: ExchangeMarket
  3198    name: string
  3199    baseID: number
  3200    quoteID: number
  3201    lotSize: number
  3202    tmpl: Record<string, PageElement>
  3203    rateConversionFactor: number
  3204  
  3205    constructor (template: HTMLElement, mkt: ExchangeMarket, rateConversionFactor: number) {
  3206      this.mkt = mkt
  3207      this.name = mkt.name
  3208      this.baseID = mkt.baseid
  3209      this.quoteID = mkt.quoteid
  3210      this.lotSize = mkt.lotsize
  3211      this.rateConversionFactor = rateConversionFactor
  3212      this.node = template.cloneNode(true) as HTMLElement
  3213      const tmpl = this.tmpl = Doc.parseTemplate(this.node)
  3214      tmpl.baseIcon.src = Doc.logoPath(mkt.basesymbol)
  3215      tmpl.quoteIcon.src = Doc.logoPath(mkt.quotesymbol)
  3216      tmpl.baseSymbol.appendChild(Doc.symbolize(mkt.xc.assets[mkt.baseid], true))
  3217      tmpl.quoteSymbol.appendChild(Doc.symbolize(mkt.xc.assets[mkt.quoteid], true))
  3218      tmpl.baseName.textContent = mkt.baseName
  3219      tmpl.host.textContent = mkt.xc.host
  3220      tmpl.host.style.color = hostColor(mkt.xc.host)
  3221      tmpl.host.dataset.tooltip = mkt.xc.host
  3222      setPriceAndChange(tmpl, mkt.xc, mkt)
  3223      if (this.mkt.xc.connectionStatus !== ConnectionStatus.Connected) Doc.show(tmpl.disconnectedIco)
  3224    }
  3225  }
  3226  
  3227  interface BalanceWidgetElement {
  3228    id: number
  3229    parentID: number
  3230    cfg: Asset | null
  3231    node: PageElement
  3232    tmpl: Record<string, PageElement>
  3233    iconBox: PageElement
  3234    stateIcons: WalletIcons
  3235    parentBal?: PageElement
  3236  }
  3237  
  3238  /*
  3239   * BalanceWidget is a display of balance information. Because the wallet can be
  3240   * in any number of states, and because every exchange has different funding
  3241   * coin confirmation requirements, the BalanceWidget displays a number of state
  3242   * indicators and buttons, as well as tabulated balance data with rows for
  3243   * locked and immature balance.
  3244   */
  3245  class BalanceWidget {
  3246    base: BalanceWidgetElement
  3247    quote: BalanceWidgetElement
  3248    // parentRow: PageElement
  3249    dex: Exchange
  3250  
  3251    constructor (base: HTMLElement, quote: HTMLElement) {
  3252      Doc.hide(base, quote)
  3253      const btmpl = Doc.parseTemplate(base)
  3254      this.base = {
  3255        id: 0,
  3256        parentID: parentIDNone,
  3257        cfg: null,
  3258        node: base,
  3259        tmpl: btmpl,
  3260        iconBox: btmpl.walletState,
  3261        stateIcons: new WalletIcons(btmpl.walletState)
  3262      }
  3263      btmpl.balanceRowTmpl.remove()
  3264  
  3265      const qtmpl = Doc.parseTemplate(quote)
  3266      this.quote = {
  3267        id: 0,
  3268        parentID: parentIDNone,
  3269        cfg: null,
  3270        node: quote,
  3271        tmpl: qtmpl,
  3272        iconBox: qtmpl.walletState,
  3273        stateIcons: new WalletIcons(qtmpl.walletState)
  3274      }
  3275      qtmpl.balanceRowTmpl.remove()
  3276  
  3277      app().registerNoteFeeder({
  3278        balance: (note: BalanceNote) => { this.updateAsset(note.assetID) },
  3279        walletstate: (note: WalletStateNote) => { this.updateAsset(note.wallet.assetID) },
  3280        walletsync: (note: WalletSyncNote) => { this.updateAsset(note.assetID) },
  3281        createwallet: (note: WalletCreationNote) => { this.updateAsset(note.assetID) }
  3282      })
  3283    }
  3284  
  3285    setBalanceVisibility (connected: boolean) {
  3286      if (connected) Doc.show(this.base.node, this.quote.node)
  3287      else Doc.hide(this.base.node, this.quote.node)
  3288    }
  3289  
  3290    /*
  3291     * setWallet sets the balance widget to display data for specified market and
  3292     * will display the widget.
  3293     */
  3294    setWallets (host: string, baseID: number, quoteID: number) {
  3295      const parentID = (assetID: number) => {
  3296        const asset = app().assets[assetID]
  3297        if (asset?.token) return asset.token.parentID
  3298        return parentIDNone
  3299      }
  3300      this.dex = app().user.exchanges[host]
  3301      this.base.id = baseID
  3302      this.base.parentID = parentID(baseID)
  3303      this.base.cfg = this.dex.assets[baseID]
  3304      this.quote.id = quoteID
  3305      this.quote.parentID = parentID(quoteID)
  3306      this.quote.cfg = this.dex.assets[quoteID]
  3307      this.updateWallet(this.base)
  3308      this.updateWallet(this.quote)
  3309      this.setBalanceVisibility(this.dex.connectionStatus === ConnectionStatus.Connected)
  3310    }
  3311  
  3312    /*
  3313     * updateWallet updates the displayed wallet information based on the
  3314     * core.Wallet state.
  3315     */
  3316    updateWallet (side: BalanceWidgetElement) {
  3317      const { cfg, tmpl, iconBox, stateIcons, id: assetID } = side
  3318      if (!cfg) return // no wallet set yet
  3319      const asset = app().assets[assetID]
  3320      // Just hide everything to start.
  3321      Doc.hide(
  3322        tmpl.newWalletRow, tmpl.expired, tmpl.unsupported, tmpl.connect, tmpl.spinner,
  3323        tmpl.walletState, tmpl.balanceRows, tmpl.walletAddr, tmpl.wantProvidersBox
  3324      )
  3325      this.checkNeedsProvider(assetID, tmpl.wantProvidersBox)
  3326      tmpl.logo.src = Doc.logoPath(cfg.symbol)
  3327      tmpl.addWalletSymbol.textContent = cfg.symbol.toUpperCase()
  3328      Doc.empty(tmpl.symbol)
  3329  
  3330      // Handle an unsupported asset.
  3331      if (!asset) {
  3332        Doc.show(tmpl.unsupported)
  3333        return
  3334      }
  3335      tmpl.symbol.appendChild(Doc.symbolize(asset, true))
  3336      Doc.show(iconBox)
  3337      const wallet = asset.wallet
  3338      stateIcons.readWallet(wallet)
  3339      // Handle no wallet configured.
  3340      if (!wallet) {
  3341        if (asset.walletCreationPending) {
  3342          Doc.show(tmpl.spinner)
  3343          return
  3344        }
  3345        Doc.show(tmpl.newWalletRow)
  3346        return
  3347      }
  3348      Doc.show(tmpl.walletAddr)
  3349      // Parent asset
  3350      const bal = wallet.balance
  3351      // Handle not connected and no balance known for the DEX.
  3352      if (!bal && !wallet.running && !wallet.disabled) {
  3353        Doc.show(tmpl.connect)
  3354        return
  3355      }
  3356      // If there is no balance, but the wallet is connected, show the loading
  3357      // icon while we fetch an update.
  3358      if (!bal) {
  3359        app().fetchBalance(assetID)
  3360        Doc.show(tmpl.spinner)
  3361        return
  3362      }
  3363  
  3364      // We have a wallet and a DEX-specific balance. Set all of the fields.
  3365      Doc.show(tmpl.balanceRows)
  3366      Doc.empty(tmpl.balanceRows)
  3367      const addRow = (title: string, bal: number, ui: UnitInfo, icon?: PageElement) => {
  3368        const row = tmpl.balanceRowTmpl.cloneNode(true) as PageElement
  3369        tmpl.balanceRows.appendChild(row)
  3370        const balTmpl = Doc.parseTemplate(row)
  3371        balTmpl.title.textContent = title
  3372        balTmpl.bal.textContent = Doc.formatCoinValue(bal, ui)
  3373        if (icon) {
  3374          balTmpl.bal.append(icon)
  3375          side.parentBal = balTmpl.bal
  3376        }
  3377      }
  3378      addRow(intl.prep(intl.ID_AVAILABLE), bal.available, asset.unitInfo)
  3379      addRow(intl.prep(intl.ID_LOCKED), bal.locked + bal.contractlocked + bal.bondlocked, asset.unitInfo)
  3380      addRow(intl.prep(intl.ID_IMMATURE), bal.immature, asset.unitInfo)
  3381      if (asset.token) {
  3382        const { wallet: { balance }, unitInfo, symbol } = app().assets[asset.token.parentID]
  3383        const icon = document.createElement('img')
  3384        icon.src = Doc.logoPath(symbol)
  3385        icon.classList.add('micro-icon', 'ms-1')
  3386        addRow(intl.prep(intl.ID_FEE_BALANCE), balance.available, unitInfo, icon)
  3387      }
  3388  
  3389      // If the current balance update time is older than an hour, show the
  3390      // expiration icon. Request a balance update, if possible.
  3391      const expired = new Date().getTime() - new Date(bal.stamp).getTime() > anHour
  3392      if (expired && !wallet.disabled) {
  3393        Doc.show(tmpl.expired)
  3394        if (wallet.running) app().fetchBalance(assetID)
  3395      } else Doc.hide(tmpl.expired)
  3396    }
  3397  
  3398    async checkNeedsProvider (assetID: number, el: PageElement) {
  3399      Doc.setVis(await app().needsCustomProvider(assetID), el)
  3400    }
  3401  
  3402    /* updateParent updates the side's parent asset balance. */
  3403    updateParent (side: BalanceWidgetElement) {
  3404      const { wallet: { balance }, unitInfo } = app().assets[side.parentID]
  3405      // firstChild is the text node set before the img child node in addRow.
  3406      if (side.parentBal?.firstChild) side.parentBal.firstChild.textContent = Doc.formatCoinValue(balance.available, unitInfo)
  3407    }
  3408  
  3409    /*
  3410     * updateAsset updates the info for one side of the existing market. If the
  3411     * specified asset ID is not one of the current market's base or quote assets,
  3412     * it is silently ignored.
  3413     */
  3414    updateAsset (assetID: number) {
  3415      if (assetID === this.base.id) this.updateWallet(this.base)
  3416      else if (assetID === this.quote.id) this.updateWallet(this.quote)
  3417      if (assetID === this.base.parentID) this.updateParent(this.base)
  3418      if (assetID === this.quote.parentID) this.updateParent(this.quote)
  3419    }
  3420  }
  3421  
  3422  /* makeMarket creates a market object that specifies basic market details. */
  3423  function makeMarket (host: string, base?: number, quote?: number) {
  3424    return {
  3425      host: host,
  3426      base: base,
  3427      quote: quote
  3428    }
  3429  }
  3430  
  3431  /* marketID creates a DEX-compatible market name from the ticker symbols. */
  3432  export function marketID (b: string, q: string) { return `${b}_${q}` }
  3433  
  3434  /* convertToAtoms converts the float string to the basic unit of a coin. */
  3435  function convertToAtoms (s: string, conversionFactor: number) {
  3436    if (!s) return 0
  3437    return Math.round(parseFloat(s) * conversionFactor)
  3438  }
  3439  
  3440  /* swapBttns changes the 'selected' class of the buttons. */
  3441  function swapBttns (before: HTMLElement, now: HTMLElement) {
  3442    before.classList.remove('selected')
  3443    now.classList.add('selected')
  3444  }
  3445  
  3446  /*
  3447   * wireOrder prepares a copy of the order with the options field converted to a
  3448   * string -> string map.
  3449   */
  3450  function wireOrder (order: TradeForm) {
  3451    const stringyOptions: Record<string, string> = {}
  3452    for (const [k, v] of Object.entries(order.options)) stringyOptions[k] = JSON.stringify(v)
  3453    return Object.assign({}, order, { options: stringyOptions })
  3454  }
  3455  
  3456  // OrderTableRowManager manages the data within a row in an order table. Each row
  3457  // represents all the orders in the order book with the same rate, but orders that
  3458  // are booked or still in the epoch queue are displayed in separate rows.
  3459  class OrderTableRowManager {
  3460    tableRow: HTMLElement
  3461    page: Record<string, PageElement>
  3462    orderBin: MiniOrder[]
  3463    sell: boolean
  3464    msgRate: number
  3465    epoch: boolean
  3466    baseUnitInfo: UnitInfo
  3467  
  3468    constructor (tableRow: HTMLElement, orderBin: MiniOrder[], baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo, rateStep: number) {
  3469      this.tableRow = tableRow
  3470      const page = this.page = Doc.parseTemplate(tableRow)
  3471      this.orderBin = orderBin
  3472      this.sell = orderBin[0].sell
  3473      this.msgRate = orderBin[0].msgRate
  3474      this.epoch = !!orderBin[0].epoch
  3475      this.baseUnitInfo = baseUnitInfo
  3476      const rateText = Doc.formatRateFullPrecision(this.msgRate, baseUnitInfo, quoteUnitInfo, rateStep)
  3477      Doc.setVis(this.isEpoch(), this.page.epoch)
  3478      if (this.msgRate === 0) {
  3479        page.rate.innerText = 'market'
  3480      } else {
  3481        const cssClass = this.isSell() ? 'sellcolor' : 'buycolor'
  3482        page.rate.innerText = rateText
  3483        page.rate.classList.add(cssClass)
  3484      }
  3485      this.updateQtyNumOrdersEl()
  3486    }
  3487  
  3488    // updateQtyNumOrdersEl populates the quantity element in the row, and also
  3489    // displays the number of orders if there is more than one order in the order
  3490    // bin.
  3491    updateQtyNumOrdersEl () {
  3492      const { page, orderBin } = this
  3493      const qty = orderBin.reduce((total, curr) => total + curr.qtyAtomic, 0)
  3494      const numOrders = orderBin.length
  3495      page.qty.innerText = Doc.formatFullPrecision(qty, this.baseUnitInfo)
  3496      if (numOrders > 1) {
  3497        page.numOrders.removeAttribute('hidden')
  3498        page.numOrders.innerText = String(numOrders)
  3499        page.numOrders.title = `quantity is comprised of ${numOrders} orders`
  3500      } else {
  3501        page.numOrders.setAttribute('hidden', 'true')
  3502      }
  3503    }
  3504  
  3505    // insertOrder adds an order to the order bin and updates the row elements
  3506    // accordingly.
  3507    insertOrder (order: MiniOrder) {
  3508      this.orderBin.push(order)
  3509      this.updateQtyNumOrdersEl()
  3510    }
  3511  
  3512    // updateOrderQuantity updates the quantity of the order identified by a token,
  3513    // if it exists in the row, and updates the row elements accordingly. The function
  3514    // returns true if the order is in the bin, and false otherwise.
  3515    updateOrderQty (update: RemainderUpdate) {
  3516      const { token, qty, qtyAtomic } = update
  3517      for (let i = 0; i < this.orderBin.length; i++) {
  3518        if (this.orderBin[i].token === token) {
  3519          this.orderBin[i].qty = qty
  3520          this.orderBin[i].qtyAtomic = qtyAtomic
  3521          this.updateQtyNumOrdersEl()
  3522          return true
  3523        }
  3524      }
  3525      return false
  3526    }
  3527  
  3528    // removeOrder removes the order identified by the token, if it exists in the row,
  3529    // and updates the row elements accordingly. If the order bin is empty, the row is
  3530    // removed from the screen. The function returns true if an order was removed, and
  3531    // false otherwise.
  3532    removeOrder (token: string) {
  3533      const index = this.orderBin.findIndex(order => order.token === token)
  3534      if (index < 0) return false
  3535      this.orderBin.splice(index, 1)
  3536      if (!this.orderBin.length) this.tableRow.remove()
  3537      else this.updateQtyNumOrdersEl()
  3538      return true
  3539    }
  3540  
  3541    // removeEpochOrders removes all the orders from the row that are not in the
  3542    // new epoch's epoch queue and updates the elements accordingly.
  3543    removeEpochOrders (newEpoch?: number) {
  3544      this.orderBin = this.orderBin.filter((order) => {
  3545        return !(order.epoch && order.epoch !== newEpoch)
  3546      })
  3547      if (!this.orderBin.length) this.tableRow.remove()
  3548      else this.updateQtyNumOrdersEl()
  3549    }
  3550  
  3551    // getRate returns the rate of the orders in the row.
  3552    getRate () {
  3553      return this.msgRate
  3554    }
  3555  
  3556    // isEpoch returns whether the orders in this row are in the epoch queue.
  3557    isEpoch () {
  3558      return this.epoch
  3559    }
  3560  
  3561    // isSell returns whether the orders in this row are sell orders.
  3562    isSell () {
  3563      return this.sell
  3564    }
  3565  
  3566    // compare takes an order and returns 0 if the order belongs in this row,
  3567    // 1 if the order should go after this row in the table, and -1 if it should
  3568    // be before this row in the table. Sell orders are displayed in ascending order,
  3569    // buy orders are displayed in descending order, and epoch orders always come
  3570    // after booked orders.
  3571    compare (order: MiniOrder) {
  3572      if (this.getRate() === order.msgRate && this.isEpoch() === !!order.epoch) {
  3573        return 0
  3574      } else if (this.getRate() !== order.msgRate) {
  3575        return (this.getRate() > order.msgRate) === order.sell ? 1 : -1
  3576      } else {
  3577        return this.isEpoch() ? 1 : -1
  3578      }
  3579    }
  3580  }
  3581  
  3582  interface ExchangeMarket extends Market {
  3583    xc: Exchange
  3584    baseName: string
  3585    bui: UnitInfo
  3586  }
  3587  
  3588  function sortedMarkets (): ExchangeMarket[] {
  3589    const mkts: ExchangeMarket[] = []
  3590    const assets = app().assets
  3591    const convertMarkets = (xc: Exchange, mkts: Market[]) => {
  3592      return mkts.map((mkt: Market) => {
  3593        const a = assets[mkt.baseid]
  3594        const baseName = a ? a.name : mkt.basesymbol
  3595        const bui = app().unitInfo(mkt.baseid, xc)
  3596        return Object.assign({ xc, baseName, bui }, mkt)
  3597      })
  3598    }
  3599    for (const xc of Object.values(app().exchanges)) mkts.push(...convertMarkets(xc, Object.values(xc.markets || {})))
  3600    mkts.sort((a: ExchangeMarket, b: ExchangeMarket): number => {
  3601      if (!a.spot) {
  3602        if (b.spot) return 1 // put b first, since we have the spot
  3603        // no spots. compare market name then host name
  3604        if (a.name === b.name) return a.xc.host.localeCompare(b.xc.host)
  3605        return a.name.localeCompare(b.name)
  3606      } else if (!b.spot) return -1 // put a first, since we have the spot
  3607      const [aLots, bLots] = [a.spot.vol24 / a.lotsize, b.spot.vol24 / b.lotsize]
  3608      return bLots - aLots // whoever has more volume by lot count
  3609    })
  3610    return mkts
  3611  }
  3612  
  3613  function setPriceAndChange (tmpl: Record<string, PageElement>, xc: Exchange, mkt: Market) {
  3614    if (!mkt.spot) return
  3615    tmpl.price.textContent = Doc.formatFourSigFigs(app().conventionalRate(mkt.baseid, mkt.quoteid, mkt.spot.rate, xc))
  3616    const sign = mkt.spot.change24 > 0 ? '+' : ''
  3617    tmpl.change.classList.remove('buycolor', 'sellcolor')
  3618    tmpl.change.classList.add(mkt.spot.change24 >= 0 ? 'buycolor' : 'sellcolor')
  3619    tmpl.change.textContent = `${sign}${(mkt.spot.change24 * 100).toFixed(1)}%`
  3620  }
  3621  
  3622  const hues = [1 / 2, 1 / 4, 3 / 4, 1 / 8, 5 / 8, 3 / 8, 7 / 8]
  3623  
  3624  function generateHue (idx: number): string {
  3625    const h = hues[idx % hues.length]
  3626    return `hsl(${h * 360}, 35%, 50%)`
  3627  }
  3628  
  3629  function hostColor (host: string): string {
  3630    const hosts = Object.keys(app().exchanges)
  3631    hosts.sort()
  3632    return generateHue(hosts.indexOf(host))
  3633  }