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

     1  import Doc, { Animation, AniToggle, parseFloatDefault, setupCopyBtn } from './doc'
     2  import BasePage from './basepage'
     3  import { postJSON, Errors } from './http'
     4  import {
     5    NewWalletForm,
     6    WalletConfigForm,
     7    DepositAddress,
     8    bind as bindForm,
     9    showSuccess
    10  } from './forms'
    11  import State from './state'
    12  import * as intl from './locales'
    13  import * as OrderUtil from './orderutil'
    14  import {
    15    app,
    16    PageElement,
    17    SupportedAsset,
    18    WalletDefinition,
    19    BalanceNote,
    20    WalletStateNote,
    21    WalletSyncNote,
    22    RateNote,
    23    Order,
    24    OrderFilter,
    25    WalletCreationNote,
    26    BaseWalletNote,
    27    WalletNote,
    28    CustomWalletNote,
    29    TipChangeNote,
    30    Exchange,
    31    Market,
    32    PeerSource,
    33    WalletPeer,
    34    ApprovalStatus,
    35    WalletState,
    36    UnitInfo,
    37    TicketStakingStatus,
    38    VotingServiceProvider,
    39    Ticket,
    40    TicketStats,
    41    TxHistoryResult,
    42    TransactionNote,
    43    WalletTransaction,
    44    FeeState
    45  } from './registry'
    46  import { CoinExplorers } from './coinexplorers'
    47  
    48  interface DecredTicketTipUpdate {
    49    ticketPrice: number
    50    votingSubsidy: number
    51    stats: TicketStats
    52  }
    53  
    54  interface TicketPurchaseUpdate extends BaseWalletNote {
    55    err?: string
    56    remaining:number
    57    tickets?: Ticket[]
    58    stats?: TicketStats
    59  }
    60  
    61  const animationLength = 300
    62  const traitRescanner = 1
    63  const traitLogFiler = 1 << 2
    64  const traitRecoverer = 1 << 5
    65  const traitWithdrawer = 1 << 6
    66  const traitRestorer = 1 << 8
    67  const traitTxFeeEstimator = 1 << 9
    68  const traitPeerManager = 1 << 10
    69  const traitTokenApprover = 1 << 13
    70  const traitTicketBuyer = 1 << 15
    71  const traitHistorian = 1 << 16
    72  const traitFundsMixer = 1 << 17
    73  
    74  const traitsExtraOpts = traitLogFiler | traitRecoverer | traitRestorer | traitRescanner | traitPeerManager | traitTokenApprover
    75  
    76  export const ticketStatusUnknown = 0
    77  export const ticketStatusUnmined = 1
    78  export const ticketStatusImmature = 2
    79  export const ticketStatusLive = 3
    80  export const ticketStatusVoted = 4
    81  export const ticketStatusMissed = 5
    82  export const ticketStatusExpired = 6
    83  export const ticketStatusUnspent = 7
    84  export const ticketStatusRevoked = 8
    85  
    86  export const ticketStatusTranslationKeys = [
    87    intl.ID_TICKET_STATUS_UNKNOWN,
    88    intl.ID_TICKET_STATUS_UNMINED,
    89    intl.ID_TICKET_STATUS_IMMATURE,
    90    intl.ID_TICKET_STATUS_LIVE,
    91    intl.ID_TICKET_STATUS_VOTED,
    92    intl.ID_TICKET_STATUS_MISSED,
    93    intl.ID_TICKET_STATUS_EXPIRED,
    94    intl.ID_TICKET_STATUS_UNSPENT,
    95    intl.ID_TICKET_STATUS_REVOKED
    96  ]
    97  
    98  export const txTypeUnknown = 0
    99  export const txTypeSend = 1
   100  export const txTypeReceive = 2
   101  export const txTypeSwap = 3
   102  export const txTypeRedeem = 4
   103  export const txTypeRefund = 5
   104  export const txTypeSplit = 6
   105  export const txTypeCreateBond = 7
   106  export const txTypeRedeemBond = 8
   107  export const txTypeApproveToken = 9
   108  export const txTypeAcceleration = 10
   109  export const txTypeSelfSend = 11
   110  export const txTypeRevokeTokenApproval = 12
   111  export const txTypeTicketPurchase = 13
   112  export const txTypeTicketVote = 14
   113  export const txTypeTicketRevocation = 15
   114  export const txTypeSwapOrSend = 16
   115  export const txTypeMixing = 17
   116  
   117  const positiveTxTypes : number[] = [
   118    txTypeReceive,
   119    txTypeRedeem,
   120    txTypeRefund,
   121    txTypeRedeemBond,
   122    txTypeTicketVote,
   123    txTypeTicketRevocation
   124  ]
   125  
   126  const negativeTxTypes : number[] = [
   127    txTypeSend,
   128    txTypeSwap,
   129    txTypeCreateBond,
   130    txTypeTicketPurchase,
   131    txTypeSwapOrSend
   132  ]
   133  
   134  const noAmtTxTypes : number[] = [
   135    txTypeSplit,
   136    txTypeApproveToken,
   137    txTypeAcceleration,
   138    txTypeRevokeTokenApproval
   139  ]
   140  
   141  function txTypeSignAndClass (txType: number): [string, string] {
   142    if (positiveTxTypes.includes(txType)) return ['+', 'positive-tx']
   143    if (negativeTxTypes.includes(txType)) return ['-', 'negative-tx']
   144    return ['', '']
   145  }
   146  
   147  const txTypeTranslationKeys = [
   148    intl.ID_TX_TYPE_UNKNOWN,
   149    intl.ID_TX_TYPE_SEND,
   150    intl.ID_TX_TYPE_RECEIVE,
   151    intl.ID_TX_TYPE_SWAP,
   152    intl.ID_TX_TYPE_REDEEM,
   153    intl.ID_TX_TYPE_REFUND,
   154    intl.ID_TX_TYPE_SPLIT,
   155    intl.ID_TX_TYPE_CREATE_BOND,
   156    intl.ID_TX_TYPE_REDEEM_BOND,
   157    intl.ID_TX_TYPE_APPROVE_TOKEN,
   158    intl.ID_TX_TYPE_ACCELERATION,
   159    intl.ID_TX_TYPE_SELF_TRANSFER,
   160    intl.ID_TX_TYPE_REVOKE_TOKEN_APPROVAL,
   161    intl.ID_TX_TYPE_TICKET_PURCHASE,
   162    intl.ID_TX_TYPE_TICKET_VOTE,
   163    intl.ID_TX_TYPE_TICKET_REVOCATION,
   164    intl.ID_TX_TYPE_SWAP_OR_SEND,
   165    intl.ID_TX_TYPE_MIX
   166  ]
   167  
   168  export function txTypeString (txType: number) : string {
   169    return intl.prep(txTypeTranslationKeys[txType])
   170  }
   171  
   172  const ticketPageSize = 10
   173  const scanStartMempool = -1
   174  
   175  interface ReconfigRequest {
   176    assetID: number
   177    walletType: string
   178    config: Record<string, string>
   179    newWalletPW?: string
   180  }
   181  
   182  interface RescanRecoveryRequest {
   183    assetID: number
   184    appPW?: string
   185    force?: boolean
   186  }
   187  
   188  interface WalletRestoration {
   189    target: string
   190    seed: string
   191    seedName: string
   192    instructions: string
   193  }
   194  
   195  interface AssetButton {
   196    tmpl: Record<string, PageElement>
   197    bttn: PageElement
   198  }
   199  
   200  interface TicketPagination {
   201    number: number
   202    history: Ticket[]
   203    scanned: boolean // Reached the end of history. All tickets cached.
   204  }
   205  
   206  interface WalletsPageData {
   207    goBack?: string
   208  }
   209  
   210  interface reconfigSettings {
   211    skipAnimation?: boolean
   212    elevateProviders?: boolean
   213  }
   214  
   215  let net = 0
   216  
   217  export default class WalletsPage extends BasePage {
   218    body: HTMLElement
   219    data?: WalletsPageData
   220    page: Record<string, PageElement>
   221    assetButtons: Record<number, AssetButton>
   222    newWalletForm: NewWalletForm
   223    reconfigForm: WalletConfigForm
   224    walletCfgGuide: PageElement
   225    depositAddrForm: DepositAddress
   226    keyup: (e: KeyboardEvent) => void
   227    changeWalletPW: boolean
   228    displayed: HTMLElement
   229    animation: Animation
   230    forms: PageElement[]
   231    forceReq: RescanRecoveryRequest
   232    forceUrl: string
   233    currentForm: PageElement
   234    restoreInfoCard: HTMLElement
   235    selectedAssetID: number
   236    stakeStatus: TicketStakingStatus
   237    maxSend: number
   238    unapprovingTokenVersion: number
   239    ticketPage: TicketPagination
   240    oldestTx: WalletTransaction | undefined
   241    currTx: WalletTransaction | undefined
   242    mixing: boolean
   243    mixerToggle: AniToggle
   244    stampers: PageElement[]
   245    secondTicker: number
   246  
   247    constructor (body: HTMLElement, data?: WalletsPageData) {
   248      super()
   249      this.body = body
   250      this.data = data
   251      const page = this.page = Doc.idDescendants(body)
   252      this.stampers = []
   253      net = app().user.net
   254  
   255      const setStamp = () => {
   256        for (const span of this.stampers) {
   257          if (span.dataset.stamp) {
   258            span.textContent = Doc.timeSince(parseInt(span.dataset.stamp || '') * 1000)
   259          }
   260        }
   261      }
   262      this.secondTicker = window.setInterval(() => {
   263        setStamp()
   264      }, 10000) // update every 10 seconds
   265  
   266      Doc.cleanTemplates(page.restoreInfoCard, page.connectedIconTmpl, page.disconnectedIconTmpl, page.removeIconTmpl)
   267      this.restoreInfoCard = page.restoreInfoCard.cloneNode(true) as HTMLElement
   268      Doc.show(page.connectedIconTmpl, page.disconnectedIconTmpl, page.removeIconTmpl)
   269  
   270      this.forms = Doc.applySelector(page.forms, ':scope > form')
   271      page.forms.querySelectorAll('.form-closer').forEach(el => {
   272        Doc.bind(el, 'click', () => { this.closePopups() })
   273      })
   274      Doc.bind(page.cancelForce, 'click', () => { this.closePopups() })
   275  
   276      this.selectedAssetID = -1
   277      Doc.cleanTemplates(
   278        page.iconSelectTmpl, page.balanceDetailRow, page.recentOrderTmpl, page.vspRowTmpl,
   279        page.ticketHistoryRowTmpl, page.votingChoiceTmpl, page.votingAgendaTmpl, page.tspendTmpl,
   280        page.tkeyTmpl, page.txHistoryRowTmpl, page.txHistoryDateRowTmpl
   281      )
   282  
   283      Doc.bind(page.createWallet, 'click', () => this.showNewWallet(this.selectedAssetID))
   284      Doc.bind(page.connectBttn, 'click', () => this.doConnect(this.selectedAssetID))
   285      Doc.bind(page.send, 'click', () => this.showSendForm(this.selectedAssetID))
   286      Doc.bind(page.receive, 'click', () => this.showDeposit(this.selectedAssetID))
   287      Doc.bind(page.unlockBttn, 'click', () => this.openWallet(this.selectedAssetID))
   288      Doc.bind(page.lockBttn, 'click', () => this.lock(this.selectedAssetID))
   289      Doc.bind(page.reconfigureBttn, 'click', () => this.showReconfig(this.selectedAssetID))
   290      Doc.bind(page.needsProviderBttn, 'click', () => this.showReconfig(this.selectedAssetID))
   291      Doc.bind(page.rescanWallet, 'click', () => this.rescanWallet(this.selectedAssetID))
   292      Doc.bind(page.earlierTxs, 'click', () => this.loadEarlierTxs())
   293  
   294      Doc.bind(page.copyTxIDBtn, 'click', () => { setupCopyBtn(this.currTx?.id || '', page.txDetailsID, page.copyTxIDBtn, '#1e7d11') })
   295      Doc.bind(page.copyRecipientBtn, 'click', () => { setupCopyBtn(this.currTx?.recipient || '', page.txDetailsRecipient, page.copyRecipientBtn, '#1e7d11') })
   296      Doc.bind(page.copyBondIDBtn, 'click', () => { setupCopyBtn(this.currTx?.bondInfo?.bondID || '', page.txDetailsBondID, page.copyBondIDBtn, '#1e7d11') })
   297      Doc.bind(page.copyBondAccountIDBtn, 'click', () => { setupCopyBtn(this.currTx?.bondInfo?.accountID || '', page.txDetailsBondAccountID, page.copyBondAccountIDBtn, '#1e7d11') })
   298      Doc.bind(page.hideMixTxsCheckbox, 'change', () => { this.showTxHistory(this.selectedAssetID) })
   299  
   300      // Bind the new wallet form.
   301      this.newWalletForm = new NewWalletForm(page.newWalletForm, (assetID: number) => {
   302        const fmtParams = { assetName: app().assets[assetID].name }
   303        this.assetUpdated(assetID, page.newWalletForm, intl.prep(intl.ID_NEW_WALLET_SUCCESS, fmtParams))
   304        this.sortAssetButtons()
   305        this.updateTicketBuyer(assetID)
   306        this.updatePrivacy(assetID)
   307      })
   308  
   309      // Bind the wallet reconfig form.
   310      this.reconfigForm = new WalletConfigForm(page.reconfigInputs, false)
   311  
   312      this.walletCfgGuide = Doc.tmplElement(page.reconfigForm, 'walletCfgGuide')
   313  
   314      // Bind the send form.
   315      bindForm(page.sendForm, page.submitSendForm, async () => { this.stepSend() })
   316      // Send confirmation form.
   317      bindForm(page.vSendForm, page.vSend, async () => { this.send() })
   318      // Bind the wallet reconfiguration submission.
   319      bindForm(page.reconfigForm, page.submitReconfig, () => this.reconfig())
   320  
   321      page.forms.querySelectorAll('.form-closer').forEach(el => {
   322        Doc.bind(el, 'click', () => this.closePopups())
   323      })
   324  
   325      Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => {
   326        if (!Doc.mouseInElement(e, this.currentForm)) { this.closePopups() }
   327      })
   328  
   329      this.mixerToggle = new AniToggle(page.toggleMixer, page.mixingErr, false, (newState: boolean) => { return this.updateMixerState(newState) })
   330  
   331      this.keyup = (e: KeyboardEvent) => {
   332        if (e.key === 'Escape') {
   333          if (Doc.isDisplayed(this.page.forms)) this.closePopups()
   334        }
   335      }
   336      Doc.bind(document, 'keyup', this.keyup)
   337  
   338      Doc.bind(page.downloadLogs, 'click', async () => { this.downloadLogs() })
   339      Doc.bind(page.exportWallet, 'click', async () => { this.displayExportWalletAuth() })
   340      Doc.bind(page.recoverWallet, 'click', async () => { this.showRecoverWallet() })
   341      bindForm(page.exportWalletAuth, page.exportWalletAuthSubmit, async () => { this.exportWalletAuthSubmit() })
   342      bindForm(page.recoverWalletConfirm, page.recoverWalletSubmit, () => { this.recoverWallet() })
   343      bindForm(page.confirmForce, page.confirmForceSubmit, async () => { this.confirmForceSubmit() })
   344      Doc.bind(page.disableWallet, 'click', async () => { this.showToggleWalletStatus(true) })
   345      Doc.bind(page.enableWallet, 'click', async () => { this.showToggleWalletStatus(false) })
   346      bindForm(page.toggleWalletStatusConfirm, page.toggleWalletStatusSubmit, async () => { this.toggleWalletStatus() })
   347      Doc.bind(page.managePeers, 'click', async () => { this.showManagePeersForm() })
   348      Doc.bind(page.addPeerSubmit, 'click', async () => { this.submitAddPeer() })
   349      Doc.bind(page.unapproveTokenAllowance, 'click', async () => { this.showUnapproveTokenAllowanceTableForm() })
   350      Doc.bind(page.unapproveTokenSubmit, 'click', async () => { this.submitUnapproveTokenAllowance() })
   351      Doc.bind(page.showVSPs, 'click', () => { this.showVSPPicker() })
   352      Doc.bind(page.vspDisplay, 'click', () => { this.showVSPPicker() })
   353      bindForm(page.vspPicker, page.customVspSubmit, async () => { this.setCustomVSP() })
   354      Doc.bind(page.purchaseTicketsBttn, 'click', () => { this.showPurchaseTicketsDialog() })
   355      bindForm(page.purchaseTicketsForm, page.purchaserSubmit, () => { this.purchaseTickets() })
   356      Doc.bind(page.purchaserInput, 'change', () => { this.purchaserInputChanged() })
   357      Doc.bind(page.ticketHistory, 'click', () => { this.showTicketHistory() })
   358      Doc.bind(page.ticketHistoryNextPage, 'click', () => { this.nextTicketPage() })
   359      Doc.bind(page.ticketHistoryPrevPage, 'click', () => { this.prevTicketPage() })
   360      Doc.bind(page.setVotes, 'click', () => { this.showSetVotesDialog() })
   361      Doc.bind(page.purchaseTicketsErrCloser, 'click', () => { Doc.hide(page.purchaseTicketsErrBox) })
   362      Doc.bind(page.privacyInfoBttn, 'click', () => { this.showForm(page.mixingInfo) })
   363  
   364      // New deposit address button.
   365      this.depositAddrForm = new DepositAddress(page.deposit)
   366  
   367      // Clicking on the available amount on the Send form populates the
   368      // amount field.
   369      Doc.bind(page.walletBal, 'click', () => { this.populateMaxSend() })
   370  
   371      // Display fiat value for current send amount.
   372      Doc.bind(page.sendAmt, 'input', () => {
   373        const { unitInfo: ui } = app().assets[this.selectedAssetID]
   374        const amt = parseFloatDefault(page.sendAmt.value)
   375        const conversionFactor = ui.conventional.conversionFactor
   376        Doc.showFiatValue(page.sendValue, amt * conversionFactor, app().fiatRatesMap[this.selectedAssetID], ui)
   377      })
   378  
   379      // Clicking on maxSend on the send form should populate the amount field.
   380      Doc.bind(page.maxSend, 'click', () => { this.populateMaxSend() })
   381  
   382      // Validate send address on input.
   383      Doc.bind(page.sendAddr, 'input', async () => {
   384        const asset = app().assets[this.selectedAssetID]
   385        page.sendAddr.classList.remove('border-danger', 'border-success')
   386        const addr = page.sendAddr.value || ''
   387        if (!asset || addr === '') return
   388        const valid = await this.validateSendAddress(addr, asset.id)
   389        if (valid) page.sendAddr.classList.add('border-success')
   390        else page.sendAddr.classList.add('border-danger')
   391      })
   392  
   393      // A link on the wallet reconfiguration form to show/hide the password field.
   394      Doc.bind(page.showChangePW, 'click', () => {
   395        this.changeWalletPW = !this.changeWalletPW
   396        this.setPWSettingViz(this.changeWalletPW)
   397      })
   398  
   399      // Changing the type of wallet.
   400      Doc.bind(page.changeWalletTypeSelect, 'change', () => {
   401        this.changeWalletType()
   402      })
   403      Doc.bind(page.showChangeType, 'click', () => {
   404        if (Doc.isHidden(page.changeWalletType)) {
   405          Doc.show(page.changeWalletType, page.changeTypeHideIcon)
   406          Doc.hide(page.changeTypeShowIcon)
   407          page.changeTypeMsg.textContent = intl.prep(intl.ID_KEEP_WALLET_TYPE)
   408        } else this.showReconfig(this.selectedAssetID, { skipAnimation: true })
   409      })
   410  
   411      app().registerNoteFeeder({
   412        fiatrateupdate: (note: RateNote) => { this.handleRatesNote(note) },
   413        balance: (note: BalanceNote) => { this.handleBalanceNote(note) },
   414        walletstate: (note: WalletStateNote) => { this.handleWalletStateNote(note) },
   415        walletconfig: (note: WalletStateNote) => { this.handleWalletStateNote(note) },
   416        walletsync: (note: WalletSyncNote) => { this.updateSyncAndPeers(note.assetID) },
   417        createwallet: (note: WalletCreationNote) => { this.handleCreateWalletNote(note) },
   418        walletnote: (note: WalletNote) => { this.handleCustomWalletNote(note) }
   419      })
   420  
   421      const firstAsset = this.sortAssetButtons()
   422      let selectedAsset = firstAsset.id
   423      const assetIDStr = State.fetchLocal(State.selectedAssetLK)
   424      if (assetIDStr) selectedAsset = Number(assetIDStr)
   425      this.setSelectedAsset(selectedAsset)
   426  
   427      setInterval(() => {
   428        for (const row of this.page.txHistoryTableBody.children) {
   429          const age = Doc.tmplElement(row as PageElement, 'age')
   430          age.textContent = Doc.timeSince(parseInt(age.dataset.timestamp as string))
   431        }
   432      }, 5000)
   433    }
   434  
   435    closePopups () {
   436      Doc.hide(this.page.forms)
   437      this.currTx = undefined
   438      if (this.animation) this.animation.stop()
   439    }
   440  
   441    async safePost (path: string, args: any): Promise<any> {
   442      const assetID = this.selectedAssetID
   443      const res = await postJSON(path, args)
   444      if (assetID !== this.selectedAssetID) throw Error('asset changed during request. aborting')
   445      return res
   446    }
   447  
   448    // stepSend makes a request to get an estimated fee and displays the confirm
   449    // send form.
   450    async stepSend () {
   451      const page = this.page
   452      Doc.hide(page.vSendErr, page.sendErr, page.vSendEstimates, page.txFeeNotAvailable)
   453      const assetID = parseInt(page.sendForm.dataset.assetID || '')
   454      const token = app().assets[assetID].token
   455      const subtract = page.subtractCheckBox.checked || false
   456      const conversionFactor = app().unitInfo(assetID).conventional.conversionFactor
   457      const value = Math.round(parseFloatDefault(page.sendAmt.value, 0) * conversionFactor)
   458      const addr = page.sendAddr.value || ''
   459      if (addr === '') return Doc.showFormError(page.sendErr, intl.prep(intl.ID_INVALID_ADDRESS_MSG, { address: addr }))
   460      const { wallet, unitInfo: ui, symbol } = app().assets[assetID]
   461  
   462      // txfee will not be available if wallet is not a fee estimator or the
   463      // request failed.
   464      let txfee = 0
   465      if ((wallet.traits & traitTxFeeEstimator) !== 0) {
   466        const open = {
   467          addr: page.sendAddr.value,
   468          assetID: assetID,
   469          subtract: subtract,
   470          value: value
   471        }
   472  
   473        const loaded = app().loading(page.sendForm)
   474        const res = await postJSON('/api/txfee', open)
   475        loaded()
   476        if (!app().checkResponse(res)) {
   477          page.txFeeNotAvailable.dataset.tooltip = intl.prep(intl.ID_TXFEE_ERR_MSG, { err: res.msg })
   478          Doc.show(page.txFeeNotAvailable)
   479          // We still want to ensure user address is valid before proceeding to send
   480          // confirm form if there's an error while calculating the transaction fee.
   481          const valid = await this.validateSendAddress(addr, assetID)
   482          if (!valid) return Doc.showFormError(page.sendErr, intl.prep(intl.ID_INVALID_ADDRESS_MSG, { address: addr || '' }))
   483        } else if (res.ok) {
   484          if (!res.validaddress) return Doc.showFormError(page.sendErr, intl.prep(intl.ID_INVALID_ADDRESS_MSG, { address: page.sendAddr.value || '' }))
   485          txfee = res.txfee
   486          Doc.show(page.vSendEstimates)
   487        }
   488      } else {
   489        // Validate only the send address for assets that are not fee estimators.
   490        const valid = await this.validateSendAddress(addr, assetID)
   491        if (!valid) return Doc.showFormError(page.sendErr, intl.prep(intl.ID_INVALID_ADDRESS_MSG, { address: addr || '' }))
   492      }
   493  
   494      page.vSendSymbol.textContent = symbol.toUpperCase()
   495      page.vSendLogo.src = Doc.logoPath(symbol)
   496  
   497      if (token) {
   498        const { unitInfo: feeUI, symbol: feeSymbol } = app().assets[token.parentID]
   499        page.vSendFee.textContent = Doc.formatFullPrecision(txfee, feeUI) + ' ' + feeSymbol
   500      } else {
   501        page.vSendFee.textContent = Doc.formatFullPrecision(txfee, ui)
   502      }
   503      const xcRate = app().fiatRatesMap[assetID]
   504      Doc.showFiatValue(page.vSendFeeFiat, txfee, xcRate, ui)
   505      page.vSendDestinationAmt.textContent = Doc.formatFullPrecision(value - txfee, ui)
   506      page.vTotalSend.textContent = Doc.formatFullPrecision(value, ui)
   507      Doc.showFiatValue(page.vTotalSendFiat, value, xcRate, ui)
   508      page.vSendAddr.textContent = page.sendAddr.value || ''
   509      const bal = wallet.balance.available - value
   510      page.balanceAfterSend.textContent = Doc.formatFullPrecision(bal, ui)
   511      Doc.showFiatValue(page.balanceAfterSendFiat, bal, xcRate, ui)
   512      Doc.show(page.approxSign)
   513      // NOTE: All tokens take this route because they cannot pay the fee.
   514      if (!subtract) {
   515        Doc.hide(page.approxSign)
   516        page.vSendDestinationAmt.textContent = Doc.formatFullPrecision(value, ui)
   517        let totalSend = value
   518        if (!token) totalSend += txfee
   519        page.vTotalSend.textContent = Doc.formatFullPrecision(totalSend, ui)
   520        Doc.showFiatValue(page.vTotalSendFiat, totalSend, xcRate, ui)
   521        let bal = wallet.balance.available - value
   522        if (!token) bal -= txfee
   523        // handle edge cases where bal is not enough to cover totalSend.
   524        // we don't want a minus display of user bal.
   525        if (bal <= 0) {
   526          page.balanceAfterSend.textContent = Doc.formatFullPrecision(0, ui)
   527          Doc.showFiatValue(page.balanceAfterSendFiat, 0, xcRate, ui)
   528        } else {
   529          page.balanceAfterSend.textContent = Doc.formatFullPrecision(bal, ui)
   530          Doc.showFiatValue(page.balanceAfterSendFiat, bal, xcRate, ui)
   531        }
   532      }
   533      Doc.hide(page.sendForm)
   534      await this.showForm(page.vSendForm)
   535    }
   536  
   537    // cancelSend displays the send form if user wants to make modification.
   538    async cancelSend () {
   539      const page = this.page
   540      Doc.hide(page.vSendForm, page.sendErr)
   541      await this.showForm(page.sendForm)
   542    }
   543  
   544    /*
   545     * validateSendAddress validates the provided address for an asset.
   546     */
   547    async validateSendAddress (addr: string, assetID: number): Promise<boolean> {
   548      const resp = await postJSON('/api/validateaddress', { addr: addr, assetID: assetID })
   549      return app().checkResponse(resp)
   550    }
   551  
   552    /*
   553     * setPWSettingViz sets the visibility of the password field section.
   554     */
   555    setPWSettingViz (visible: boolean) {
   556      const page = this.page
   557      if (visible) {
   558        Doc.hide(page.showIcon)
   559        Doc.show(page.hideIcon, page.changePW)
   560        page.switchPWMsg.textContent = intl.prep(intl.ID_KEEP_WALLET_PASS)
   561        return
   562      }
   563      Doc.hide(page.hideIcon, page.changePW)
   564      Doc.show(page.showIcon)
   565      page.switchPWMsg.textContent = intl.prep(intl.ID_NEW_WALLET_PASS)
   566    }
   567  
   568    /*
   569     * assetVersionUsedByDEXes returns a map of the versions of the
   570     * currently selected asset to the DEXes that use that version.
   571     */
   572    assetVersionUsedByDEXes (): Record<number, string[]> {
   573      const assetID = this.selectedAssetID
   574      const versionToDEXes = {} as Record<number, string[]>
   575      const exchanges = app().exchanges
   576  
   577      for (const host in exchanges) {
   578        const exchange = exchanges[host]
   579        const exchangeAsset = exchange.assets[assetID]
   580        if (!exchangeAsset) continue
   581        if (!versionToDEXes[exchangeAsset.version]) {
   582          versionToDEXes[exchangeAsset.version] = []
   583        }
   584        versionToDEXes[exchangeAsset.version].push(exchange.host)
   585      }
   586  
   587      return versionToDEXes
   588    }
   589  
   590    /*
   591     * submitUnapproveTokenAllowance submits a request to the server to
   592     * unapprove a version of the currently selected token's swap contract.
   593     */
   594    async submitUnapproveTokenAllowance () {
   595      const page = this.page
   596      const path = '/api/unapprovetoken'
   597      const res = await postJSON(path, {
   598        assetID: this.selectedAssetID,
   599        version: this.unapprovingTokenVersion
   600      })
   601      if (!app().checkResponse(res)) {
   602        page.unapproveTokenErr.textContent = res.msg
   603        Doc.show(page.unapproveTokenErr)
   604        return
   605      }
   606  
   607      const assetExplorer = CoinExplorers[this.selectedAssetID]
   608      if (assetExplorer && assetExplorer[net]) {
   609        page.unapproveTokenTxID.href = assetExplorer[net](res.txID)
   610      }
   611      page.unapproveTokenTxID.textContent = res.txID
   612      Doc.hide(page.unapproveTokenSubmissionElements, page.unapproveTokenErr)
   613      Doc.show(page.unapproveTokenTxMsg)
   614    }
   615  
   616    /*
   617     * showUnapproveTokenAllowanceForm displays the form for unapproving
   618     * a specific version of the currently selected token's swap contract.
   619     */
   620    async showUnapproveTokenAllowanceForm (version: number) {
   621      const page = this.page
   622      this.unapprovingTokenVersion = version
   623      Doc.show(page.unapproveTokenSubmissionElements)
   624      Doc.hide(page.unapproveTokenTxMsg, page.unapproveTokenErr)
   625      const asset = app().assets[this.selectedAssetID]
   626      if (!asset || !asset.token) return
   627      const parentAsset = app().assets[asset.token.parentID]
   628      if (!parentAsset) return
   629      Doc.empty(page.tokenAllowanceRemoveSymbol)
   630      page.tokenAllowanceRemoveSymbol.appendChild(Doc.symbolize(asset, true))
   631      page.tokenAllowanceRemoveVersion.textContent = version.toString()
   632  
   633      const path = '/api/approvetokenfee'
   634      const res = await postJSON(path, {
   635        assetID: this.selectedAssetID,
   636        version: version,
   637        approving: false
   638      })
   639      if (!app().checkResponse(res)) {
   640        page.unapproveTokenErr.textContent = res.msg
   641        Doc.show(page.unapproveTokenErr)
   642      } else {
   643        let feeText = `${Doc.formatCoinValue(res.txFee, parentAsset.unitInfo)} ${parentAsset.unitInfo.conventional.unit}`
   644        const rate = app().fiatRatesMap[parentAsset.id]
   645        if (rate) {
   646          feeText += ` (${Doc.formatFiatConversion(res.txFee, rate, parentAsset.unitInfo)} USD)`
   647        }
   648        page.unapprovalFeeEstimate.textContent = feeText
   649      }
   650      this.showForm(page.unapproveTokenForm)
   651    }
   652  
   653    /*
   654     * showUnapproveTokenAllowanceTableForm displays a table showing each of the
   655     * versions of a token's swap contract that have been approved and allows the
   656     * user to unapprove any of them.
   657     */
   658    async showUnapproveTokenAllowanceTableForm () {
   659      const page = this.page
   660      const asset = app().assets[this.selectedAssetID]
   661      if (!asset || !asset.wallet || !asset.wallet.approved) return
   662      while (page.tokenVersionBody.firstChild) {
   663        page.tokenVersionBody.removeChild(page.tokenVersionBody.firstChild)
   664      }
   665      Doc.empty(page.tokenVersionTableAssetSymbol)
   666      page.tokenVersionTableAssetSymbol.appendChild(Doc.symbolize(asset, true))
   667      const versionToDEXes = this.assetVersionUsedByDEXes()
   668  
   669      let showTable = false
   670      for (let i = 0; i <= asset.wallet.version; i++) {
   671        const approvalStatus = asset.wallet.approved[i]
   672        if (approvalStatus === undefined || approvalStatus !== ApprovalStatus.Approved) {
   673          continue
   674        }
   675        showTable = true
   676        const row = page.tokenVersionRow.cloneNode(true) as PageElement
   677        const tmpl = Doc.parseTemplate(row)
   678        tmpl.version.textContent = i.toString()
   679        if (versionToDEXes[i]) {
   680          tmpl.usedBy.textContent = versionToDEXes[i].join(', ')
   681        }
   682        const removeIcon = this.page.removeIconTmpl.cloneNode(true)
   683        Doc.bind(removeIcon, 'click', () => {
   684          this.showUnapproveTokenAllowanceForm(i)
   685        })
   686        tmpl.remove.appendChild(removeIcon)
   687        page.tokenVersionBody.appendChild(row)
   688      }
   689      Doc.setVis(showTable, page.tokenVersionTable)
   690      Doc.setVis(!showTable, page.tokenVersionNone)
   691      this.showForm(page.unapproveTokenTableForm)
   692    }
   693  
   694    /*
   695     * updateWalletPeers retrieves the wallet peers and displays them in the
   696     * wallet peers table.
   697     */
   698    async updateWalletPeersTable () {
   699      const page = this.page
   700  
   701      Doc.hide(page.peerSpinner)
   702  
   703      const res = await postJSON('/api/getwalletpeers', {
   704        assetID: this.selectedAssetID
   705      })
   706      if (!app().checkResponse(res)) {
   707        page.managePeersErr.textContent = res.msg
   708        Doc.show(page.managePeersErr)
   709        return
   710      }
   711  
   712      while (page.peersTableBody.firstChild) {
   713        page.peersTableBody.removeChild(page.peersTableBody.firstChild)
   714      }
   715  
   716      const peers : WalletPeer[] = res.peers || []
   717      peers.sort((a: WalletPeer, b: WalletPeer) : number => {
   718        return a.source - b.source
   719      })
   720  
   721      const defaultText = intl.prep(intl.ID_DEFAULT)
   722      const addedText = intl.prep(intl.ID_ADDED)
   723      const discoveredText = intl.prep(intl.ID_DISCOVERED)
   724  
   725      peers.forEach((peer: WalletPeer) => {
   726        const row = page.peerTableRow.cloneNode(true) as PageElement
   727        const tmpl = Doc.parseTemplate(row)
   728  
   729        tmpl.addr.textContent = peer.addr
   730  
   731        switch (peer.source) {
   732          case PeerSource.WalletDefault:
   733            tmpl.source.textContent = defaultText
   734            break
   735          case PeerSource.UserAdded:
   736            tmpl.source.textContent = addedText
   737            break
   738          case PeerSource.Discovered:
   739            tmpl.source.textContent = discoveredText
   740            break
   741        }
   742  
   743        let connectionIcon
   744        if (peer.connected) {
   745          connectionIcon = this.page.connectedIconTmpl.cloneNode(true)
   746        } else {
   747          connectionIcon = this.page.disconnectedIconTmpl.cloneNode(true)
   748        }
   749        tmpl.connected.appendChild(connectionIcon)
   750  
   751        if (peer.source === PeerSource.UserAdded) {
   752          const removeIcon = this.page.removeIconTmpl.cloneNode(true)
   753          Doc.bind(removeIcon, 'click', async () => {
   754            Doc.hide(page.managePeersErr)
   755            const res = await postJSON('/api/removewalletpeer', {
   756              assetID: this.selectedAssetID,
   757              addr: peer.addr
   758            })
   759            if (!app().checkResponse(res)) {
   760              page.managePeersErr.textContent = res.msg
   761              Doc.show(page.managePeersErr)
   762              return
   763            }
   764            this.spinUntilPeersUpdate()
   765          })
   766          tmpl.remove.appendChild(removeIcon)
   767        }
   768  
   769        page.peersTableBody.appendChild(row)
   770      })
   771    }
   772  
   773    // showManagePeersForm displays the manage peers form.
   774    async showManagePeersForm () {
   775      const page = this.page
   776      await this.updateWalletPeersTable()
   777      Doc.hide(page.managePeersErr)
   778      this.showForm(page.managePeersForm)
   779    }
   780  
   781    // submitAddPeers sends a request for the the wallet to connect to a new
   782    // peer.
   783    async submitAddPeer () {
   784      const page = this.page
   785      Doc.hide(page.managePeersErr)
   786      const res = await postJSON('/api/addwalletpeer', {
   787        assetID: this.selectedAssetID,
   788        addr: page.addPeerInput.value
   789      })
   790      if (!app().checkResponse(res)) {
   791        page.managePeersErr.textContent = res.msg
   792        Doc.show(page.managePeersErr)
   793        return
   794      }
   795      this.spinUntilPeersUpdate()
   796      page.addPeerInput.value = ''
   797    }
   798  
   799    /*
   800     * spinUntilPeersUpdate will show the spinner on the manage peers fork.
   801     * If it is still showing after 10 seconds, the peers table will be updated
   802     * instead of waiting for a notification.
   803     */
   804    async spinUntilPeersUpdate () {
   805      const page = this.page
   806      Doc.show(page.peerSpinner)
   807      setTimeout(() => {
   808        if (Doc.isDisplayed(page.peerSpinner)) {
   809          this.updateWalletPeersTable()
   810        }
   811      }, 10000)
   812    }
   813  
   814    /*
   815     * showToggleWalletStatus displays the toggleWalletStatusConfirm form with
   816     * relevant help message.
   817     */
   818    showToggleWalletStatus (disable: boolean) {
   819      const page = this.page
   820      Doc.hide(page.toggleWalletStatusErr, page.walletStatusDisable, page.disableWalletMsg, page.walletStatusEnable, page.enableWalletMsg)
   821      if (disable) Doc.show(page.walletStatusDisable, page.disableWalletMsg)
   822      else Doc.show(page.walletStatusEnable, page.enableWalletMsg)
   823      this.showForm(page.toggleWalletStatusConfirm)
   824    }
   825  
   826    /*
   827     * toggleWalletStatus toggles a wallets status to either disabled or enabled.
   828     */
   829    async toggleWalletStatus () {
   830      const page = this.page
   831      Doc.hide(page.toggleWalletStatusErr)
   832  
   833      const asset = app().assets[this.selectedAssetID]
   834      const disable = !asset.wallet.disabled
   835      const url = '/api/togglewalletstatus'
   836      const req = {
   837        assetID: this.selectedAssetID,
   838        disable: disable
   839      }
   840  
   841      const fmtParams = { assetName: asset.name }
   842      const loaded = app().loading(page.toggleWalletStatusConfirm)
   843      const res = await postJSON(url, req)
   844      loaded()
   845      if (!app().checkResponse(res)) {
   846        if (res.code === Errors.activeOrdersErr) page.toggleWalletStatusErr.textContent = intl.prep(intl.ID_ACTIVE_ORDERS_ERR_MSG, fmtParams)
   847        else page.toggleWalletStatusErr.textContent = res.msg
   848        Doc.show(page.toggleWalletStatusErr)
   849        return
   850      }
   851  
   852      let successMsg = intl.prep(intl.ID_WALLET_DISABLED_MSG, fmtParams)
   853      if (!disable) successMsg = intl.prep(intl.ID_WALLET_ENABLED_MSG, fmtParams)
   854      this.assetUpdated(this.selectedAssetID, page.toggleWalletStatusConfirm, successMsg)
   855    }
   856  
   857    /*
   858     * showBox shows the box with a fade-in animation.
   859     */
   860    async showBox (box: HTMLElement, focuser?: PageElement) {
   861      box.style.opacity = '0'
   862      Doc.show(box)
   863      if (focuser) focuser.focus()
   864      await Doc.animate(animationLength, progress => {
   865        box.style.opacity = `${progress}`
   866      }, 'easeOut')
   867      box.style.opacity = '1'
   868      this.displayed = box
   869    }
   870  
   871    /* showForm shows a modal form with a little animation. */
   872    async showForm (form: PageElement) {
   873      const page = this.page
   874      this.currentForm = form
   875      this.forms.forEach(form => Doc.hide(form))
   876      form.style.right = '10000px'
   877      Doc.show(page.forms, form)
   878      const shift = (page.forms.offsetWidth + form.offsetWidth) / 2
   879      await Doc.animate(animationLength, progress => {
   880        form.style.right = `${(1 - progress) * shift}px`
   881      }, 'easeOutHard')
   882      form.style.right = '0'
   883    }
   884  
   885    async showSuccess (msg: string) {
   886      this.forms.forEach(form => Doc.hide(form))
   887      this.currentForm = this.page.checkmarkForm
   888      this.animation = showSuccess(this.page, msg)
   889      await this.animation.wait()
   890      this.animation = new Animation(1500, () => { /* pass */ }, '', () => {
   891        if (this.currentForm === this.page.checkmarkForm) this.closePopups()
   892      })
   893    }
   894  
   895    /* Show the new wallet form. */
   896    async showNewWallet (assetID: number) {
   897      const page = this.page
   898      const box = page.newWalletForm
   899      this.newWalletForm.setAsset(assetID)
   900      const defaultsLoaded = this.newWalletForm.loadDefaults()
   901      await this.showForm(box)
   902      await defaultsLoaded
   903    }
   904  
   905    // sortAssetButtons displays supported assets, sorted. Returns first asset in the
   906    // list.
   907    sortAssetButtons (): SupportedAsset {
   908      const page = this.page
   909      this.assetButtons = {}
   910      Doc.empty(page.assetSelect)
   911      const sortedAssets = [...Object.values(app().assets)]
   912      sortedAssets.sort((a: SupportedAsset, b: SupportedAsset) => {
   913        if (a.wallet && !b.wallet) return -1
   914        if (!a.wallet && b.wallet) return 1
   915        if (!a.wallet && !b.wallet) return a.symbol === 'dcr' ? -1 : 1
   916        const [aBal, bBal] = [a.wallet.balance, b.wallet.balance]
   917        const [aTotal, bTotal] = [aBal.available + aBal.immature + aBal.locked, bBal.available + bBal.immature + bBal.locked]
   918        if (aTotal === 0 && bTotal === 0) return a.symbol.localeCompare(b.symbol)
   919        else if (aTotal === 0) return 1
   920        else if (aTotal === 0) return -1
   921        const [aFiat, bFiat] = [app().fiatRatesMap[a.id], app().fiatRatesMap[b.id]]
   922        if (aFiat && !bFiat) return -1
   923        if (!aFiat && bFiat) return 1
   924        return bFiat * bTotal - aFiat * aTotal
   925      })
   926      for (const a of sortedAssets) {
   927        const bttn = page.iconSelectTmpl.cloneNode(true) as HTMLElement
   928        page.assetSelect.appendChild(bttn)
   929        const tmpl = Doc.parseTemplate(bttn)
   930        this.assetButtons[a.id] = { tmpl, bttn }
   931        this.updateAssetButton(a.id)
   932        Doc.bind(bttn, 'click', () => {
   933          this.setSelectedAsset(a.id)
   934          State.storeLocal(State.selectedAssetLK, String(a.id))
   935        })
   936      }
   937      page.assetSelect.classList.remove('invisible')
   938      return sortedAssets[0]
   939    }
   940  
   941    updateAssetButton (assetID: number) {
   942      const a = app().assets[assetID]
   943      const { bttn, tmpl } = this.assetButtons[assetID]
   944      Doc.hide(tmpl.fiatBox, tmpl.noWallet)
   945      bttn.classList.add('nowallet')
   946      tmpl.img.src ||= Doc.logoPath(a.symbol) // don't initiate GET if already set (e.g. update on some notification)
   947      const symbolParts = a.symbol.split('.')
   948      if (symbolParts.length === 2) {
   949        const parentSymbol = symbolParts[1]
   950        tmpl.parentImg.classList.remove('d-hide')
   951        tmpl.parentImg.src ||= Doc.logoPath(parentSymbol)
   952      }
   953      if (this.selectedAssetID === assetID) bttn.classList.add('selected')
   954      tmpl.name.textContent = a.name
   955      if (a.wallet) {
   956        bttn.classList.remove('nowallet')
   957        const { wallet: { balance: b }, unitInfo: ui } = a
   958        const totalBalance = b.available + b.locked + b.immature
   959        const [s, unit] = Doc.formatBestUnitsFourSigFigs(totalBalance, ui)
   960        tmpl.balance.textContent = s
   961        tmpl.unit.textContent = unit
   962        Doc.show(tmpl.balanceBox)
   963        const fiatRate = app().fiatRatesMap[a.id]
   964        if (fiatRate) {
   965          Doc.show(tmpl.fiatBox)
   966          tmpl.fiat.textContent = Doc.formatFourSigFigs(totalBalance / ui.conventional.conversionFactor * fiatRate)
   967        }
   968      } else Doc.show(tmpl.noWallet)
   969    }
   970  
   971    async setSelectedAsset (assetID: number) {
   972      const { assetSelect } = this.page
   973      for (const b of assetSelect.children) b.classList.remove('selected')
   974      this.assetButtons[assetID].bttn.classList.add('selected')
   975      this.selectedAssetID = assetID
   976      this.page.hideMixTxsCheckbox.checked = true
   977      this.updateDisplayedAsset(assetID)
   978      this.showAvailableMarkets(assetID)
   979      const a = this.showRecentActivity(assetID)
   980      const b = this.showTxHistory(assetID)
   981      const c = this.updateTicketBuyer(assetID)
   982      const d = this.updatePrivacy(assetID)
   983      for (const p of [a, b, c, d]) await p
   984    }
   985  
   986    updateDisplayedAsset (assetID: number) {
   987      if (assetID !== this.selectedAssetID) return
   988      const { symbol, wallet, name, token, unitInfo } = app().assets[assetID]
   989      const { page, body } = this
   990      Doc.setText(body, '[data-asset-name]', name)
   991      Doc.setText(body, '[data-ticker]', unitInfo.conventional.unit)
   992      page.assetLogo.src = Doc.logoPath(symbol)
   993      Doc.hide(
   994        page.balanceBox, page.fiatBalanceBox, page.createWallet, page.walletDetails,
   995        page.sendReceive, page.connectBttnBox, page.statusLocked, page.statusReady,
   996        page.statusOff, page.unlockBttnBox, page.lockBttnBox, page.connectBttnBox,
   997        page.peerCountBox, page.syncProgressBox, page.statusDisabled, page.tokenInfoBox,
   998        page.needsProviderBox, page.feeStateBox, page.txSyncBox, page.txProgress,
   999        page.txFindingAddrs
  1000      )
  1001      this.checkNeedsProvider(assetID)
  1002      if (token) {
  1003        const parentAsset = app().assets[token.parentID]
  1004        page.tokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
  1005        page.tokenParentName.textContent = parentAsset.name
  1006        page.contractAddress.textContent = token.contractAddress
  1007        Doc.show(page.tokenInfoBox)
  1008      }
  1009      if (wallet) {
  1010        this.updateDisplayedAssetBalance()
  1011        const { feeState, running, disabled, type: walletType } = wallet
  1012  
  1013        const walletDef = app().walletDefinition(assetID, walletType)
  1014        page.walletType.textContent = walletDef.tab
  1015        if (feeState) this.updateFeeState(feeState)
  1016        if (disabled) Doc.show(page.statusDisabled) // wallet is disabled
  1017        else if (running) {
  1018          this.updateSyncAndPeers(wallet.assetID)
  1019        } else Doc.show(page.statusOff, page.connectBttnBox) // wallet not running
  1020      } else Doc.show(page.createWallet) // no wallet
  1021  
  1022      page.walletDetailsBox.classList.remove('invisible')
  1023    }
  1024  
  1025    updateSyncAndPeers (assetID: number) {
  1026      const { page, selectedAssetID } = this
  1027      if (assetID !== selectedAssetID) return
  1028      const { peerCount, syncProgress, syncStatus, encrypted, open, running } = app().walletMap[assetID]
  1029      if (!running) return
  1030      Doc.show(page.sendReceive, page.peerCountBox, page.syncProgressBox)
  1031      page.peerCount.textContent = String(peerCount)
  1032      page.syncProgress.textContent = `${(syncProgress * 100).toFixed(1)}%`
  1033      if (open) {
  1034        Doc.show(page.statusReady)
  1035        if (!app().haveActiveOrders(assetID) && encrypted) Doc.show(page.lockBttnBox)
  1036      } else Doc.show(page.statusLocked, page.unlockBttnBox) // wallet not unlocked
  1037      Doc.setVis(syncStatus.txs !== undefined, page.txSyncBox)
  1038      if (syncStatus.txs !== undefined) {
  1039        Doc.hide(page.txProgress, page.txFindingAddrs)
  1040        if (syncStatus.txs === 0 && syncStatus.blocks >= syncStatus.targetHeight) Doc.show(page.txFindingAddrs)
  1041        else {
  1042          Doc.show(page.txProgress)
  1043          const prog = syncStatus.txs / syncStatus.targetHeight
  1044          page.txProgress.textContent = `${(prog * 100).toFixed(1)}%`
  1045        }
  1046      }
  1047    }
  1048  
  1049    updateFeeState (feeState: FeeState) {
  1050      const { page, selectedAssetID: assetID } = this
  1051      Doc.hide(page.feeStateBox)
  1052      const { unitInfo: ui, token } = app().assets[assetID]
  1053      const fiatRate = app().fiatRatesMap[assetID]
  1054      if (!fiatRate) return
  1055      const feeAssetID = token ? token.parentID : assetID
  1056      const feeFiatRate = app().fiatRatesMap[feeAssetID]
  1057      if (token && !feeFiatRate) return
  1058      Doc.show(page.feeStateBox)
  1059      const feeUI = token ? app().assets[token.parentID].unitInfo : ui
  1060      Doc.formatBestRateElement(page.feeStateNetRate, feeAssetID, feeState.rate, feeUI)
  1061      Doc.formatBestValueElement(page.feeStateSendFees, feeAssetID, feeState.send, feeUI)
  1062      Doc.formatBestValueElement(page.feeStateSwapFees, feeAssetID, feeState.swap, feeUI)
  1063      Doc.formatBestValueElement(page.feeStateRedeemFees, feeAssetID, feeState.redeem, feeUI)
  1064      page.feeStateXcRate.textContent = Doc.formatFourSigFigs(fiatRate)
  1065      const sendFiat = feeState.send / feeUI.conventional.conversionFactor * feeFiatRate
  1066      page.feeStateSendFiat.textContent = Doc.formatFourSigFigs(sendFiat)
  1067      const swapFiat = feeState.swap / feeUI.conventional.conversionFactor * feeFiatRate
  1068      page.feeStateSwapFiat.textContent = Doc.formatFourSigFigs(swapFiat)
  1069      const redeemFiat = feeState.redeem / feeUI.conventional.conversionFactor * feeFiatRate
  1070      page.feeStateRedeemFiat.textContent = Doc.formatFourSigFigs(redeemFiat)
  1071      Doc.show(page.feeStateBox)
  1072    }
  1073  
  1074    async checkNeedsProvider (assetID: number) {
  1075      const needs = await app().needsCustomProvider(assetID)
  1076      const { page: { needsProviderBox: box, needsProviderBttn: bttn } } = this
  1077      Doc.setVis(needs, box)
  1078      if (!needs) return
  1079      Doc.blink(bttn)
  1080    }
  1081  
  1082    async updateTicketBuyer (assetID: number) {
  1083      this.ticketPage = {
  1084        number: 0,
  1085        history: [],
  1086        scanned: false
  1087      }
  1088      const { wallet, unitInfo: ui } = app().assets[assetID]
  1089      const page = this.page
  1090      Doc.hide(
  1091        page.stakingBox, page.pickVSP, page.stakingSummary, page.stakingErr,
  1092        page.vspDisplayBox, page.ticketPriceBox, page.purchaseTicketsBox,
  1093        page.stakingRpcSpvMsg, page.ticketsDisabled
  1094      )
  1095      if (!wallet?.running || (wallet.traits & traitTicketBuyer) === 0) return
  1096      Doc.show(page.stakingBox)
  1097      const loaded = app().loading(page.stakingBox)
  1098      const res = await this.safePost('/api/stakestatus', assetID)
  1099      loaded()
  1100      if (!app().checkResponse(res)) {
  1101        // Look for common error for RPC + SPV wallet.
  1102        if (res.msg.includes('disconnected from consensus RPC')) {
  1103          Doc.show(page.stakingRpcSpvMsg)
  1104          return
  1105        }
  1106        Doc.show(page.stakingErr)
  1107        page.stakingErr.textContent = res.msg
  1108        return
  1109      }
  1110      Doc.show(page.stakingSummary, page.ticketPriceBox)
  1111      const stakeStatus = res.status as TicketStakingStatus
  1112      this.stakeStatus = stakeStatus
  1113      page.stakingAgendaCount.textContent = String(stakeStatus.stances.agendas.length)
  1114      page.stakingTspendCount.textContent = String(stakeStatus.stances.tspends.length)
  1115      page.purchaserCurrentPrice.textContent = Doc.formatFourSigFigs(stakeStatus.ticketPrice / ui.conventional.conversionFactor)
  1116      page.purchaserBal.textContent = Doc.formatCoinValue(wallet.balance.available, ui)
  1117      this.updateTicketStats(stakeStatus.stats, ui, stakeStatus.ticketPrice, stakeStatus.votingSubsidy)
  1118      // If this is an extension wallet, we'll might to disable all controls.
  1119      const disableStaking = app().extensionWallet(this.selectedAssetID)?.disableStaking
  1120      if (disableStaking) {
  1121        Doc.hide(page.setVotes, page.showVSPs)
  1122        Doc.show(page.ticketsDisabled)
  1123        page.extensionModeAppName.textContent = app().user.extensionModeConfig.name
  1124        return
  1125      }
  1126  
  1127      this.setVSPViz(stakeStatus.vsp)
  1128    }
  1129  
  1130    setVSPViz (vsp: string) {
  1131      const { page, stakeStatus } = this
  1132      Doc.hide(page.vspDisplayBox)
  1133      if (vsp) {
  1134        Doc.show(page.vspDisplayBox, page.purchaseTicketsBox)
  1135        Doc.hide(page.pickVSP)
  1136        page.vspURL.textContent = vsp
  1137        return
  1138      }
  1139      Doc.setVis(!stakeStatus.isRPC, page.pickVSP)
  1140      Doc.setVis(stakeStatus.isRPC, page.purchaseTicketsBox)
  1141    }
  1142  
  1143    updateTicketStats (stats: TicketStats, ui: UnitInfo, ticketPrice?: number, votingSubsidy?: number) {
  1144      const { page, stakeStatus } = this
  1145      stakeStatus.stats = stats
  1146      if (ticketPrice) stakeStatus.ticketPrice = ticketPrice
  1147      if (votingSubsidy) stakeStatus.votingSubsidy = votingSubsidy
  1148      const liveTicketCount = stakeStatus.tickets.filter((tkt: Ticket) => tkt.status <= ticketStatusLive && tkt.status >= ticketStatusUnmined).length
  1149      page.stakingTicketCount.textContent = String(liveTicketCount)
  1150      page.immatureTicketCount.textContent = String(stats.mempool)
  1151      Doc.setVis(stats.mempool > 0, page.immatureTicketCountBox)
  1152      page.queuedTicketCount.textContent = String(stats.queued)
  1153      page.formQueuedTix.textContent = String(stats.queued)
  1154      Doc.setVis(stats.queued > 0, page.formQueueTixBox, page.queuedTicketCountBox)
  1155      page.totalTicketCount.textContent = String(stats.ticketCount)
  1156      page.totalTicketRewards.textContent = Doc.formatFourSigFigs(stats.totalRewards / ui.conventional.conversionFactor)
  1157      page.totalTicketVotes.textContent = String(stats.votes)
  1158      if (ticketPrice) page.ticketPrice.textContent = Doc.formatFourSigFigs(ticketPrice / ui.conventional.conversionFactor)
  1159      if (votingSubsidy) page.votingSubsidy.textContent = Doc.formatFourSigFigs(votingSubsidy / ui.conventional.conversionFactor)
  1160    }
  1161  
  1162    async showVSPPicker () {
  1163      const assetID = this.selectedAssetID
  1164      const page = this.page
  1165      this.showForm(page.vspPicker)
  1166      Doc.empty(page.vspPickerList)
  1167      Doc.hide(page.stakingErr)
  1168      const loaded = app().loading(page.vspPicker)
  1169      const res = await this.safePost('/api/listvsps', assetID)
  1170      loaded()
  1171      if (!app().checkResponse(res)) {
  1172        Doc.show(page.stakingErr)
  1173        page.stakingErr.textContent = res.msg
  1174        return
  1175      }
  1176      const vsps = res.vsps as VotingServiceProvider[]
  1177      for (const vsp of vsps) {
  1178        const row = page.vspRowTmpl.cloneNode(true) as PageElement
  1179        page.vspPickerList.appendChild(row)
  1180        const tmpl = Doc.parseTemplate(row)
  1181        tmpl.url.textContent = vsp.url
  1182        tmpl.feeRate.textContent = vsp.feePercentage.toFixed(2)
  1183        tmpl.voting.textContent = String(vsp.voting)
  1184        Doc.bind(row, 'click', () => {
  1185          Doc.hide(page.stakingErr)
  1186          this.setVSP(assetID, vsp)
  1187        })
  1188      }
  1189    }
  1190  
  1191    showPurchaseTicketsDialog () {
  1192      const page = this.page
  1193      page.purchaserInput.value = ''
  1194      Doc.hide(page.purchaserErr)
  1195      this.showForm(this.page.purchaseTicketsForm)
  1196      page.purchaserInput.focus()
  1197    }
  1198  
  1199    purchaserInputChanged () {
  1200      const page = this.page
  1201      const n = parseInt(page.purchaserInput.value || '0')
  1202      if (n <= 1) {
  1203        page.purchaserInput.value = '1'
  1204        return
  1205      }
  1206      page.purchaserInput.value = String(n)
  1207    }
  1208  
  1209    async purchaseTickets () {
  1210      const { page, selectedAssetID: assetID } = this
  1211      // DRAFT NOTE: The user will get an actual ticket count somewhere in the
  1212      // range 1 <= tickets_purchased <= n. See notes in
  1213      // (*spvWallet).PurchaseTickets.
  1214      // How do we handle this at the UI. Or do we handle it all in the backend
  1215      // somehow?
  1216      const n = parseInt(page.purchaserInput.value || '0')
  1217      if (n < 1) return
  1218      // TODO: Add confirmation dialog.
  1219      const loaded = app().loading(page.purchaseTicketsForm)
  1220      const res = await this.safePost('/api/purchasetickets', { assetID, n })
  1221      loaded()
  1222      if (!app().checkResponse(res)) {
  1223        page.purchaserErr.textContent = res.msg
  1224        Doc.show(page.purchaserErr)
  1225        return
  1226      }
  1227      this.showSuccess(intl.prep(intl.ID_TICKETS_PURCHASED, { n: n.toLocaleString(Doc.languages()) }))
  1228    }
  1229  
  1230    processTicketPurchaseUpdate (walletNote: CustomWalletNote) {
  1231      const { stakeStatus, selectedAssetID, page } = this
  1232      const { assetID } = walletNote
  1233      const { err, remaining, tickets, stats } = walletNote.payload as TicketPurchaseUpdate
  1234      if (assetID !== selectedAssetID) return
  1235      if (err) {
  1236        Doc.show(page.purchaseTicketsErrBox)
  1237        page.purchaseTicketsErr.textContent = err
  1238        return
  1239      }
  1240      if (tickets) stakeStatus.tickets = tickets.concat(stakeStatus.tickets)
  1241      if (stats) this.updateTicketStats(stats, app().assets[assetID].unitInfo)
  1242      stakeStatus.stats.queued = remaining
  1243      page.queuedTicketCount.textContent = String(remaining)
  1244      page.formQueuedTix.textContent = String(remaining)
  1245      Doc.setVis(remaining > 0, page.queuedTicketCountBox)
  1246    }
  1247  
  1248    async setVSP (assetID: number, vsp: VotingServiceProvider) {
  1249      this.closePopups()
  1250      const page = this.page
  1251      const loaded = app().loading(page.stakingBox)
  1252      const res = await this.safePost('/api/setvsp', { assetID, url: vsp.url })
  1253      loaded()
  1254      if (!app().checkResponse(res)) {
  1255        Doc.show(page.stakingErr)
  1256        page.stakingErr.textContent = res.msg
  1257        return
  1258      }
  1259      this.setVSPViz(vsp.url)
  1260    }
  1261  
  1262    setCustomVSP () {
  1263      const assetID = this.selectedAssetID
  1264      const vsp = { url: this.page.customVspUrl.value } as VotingServiceProvider
  1265      this.setVSP(assetID, vsp)
  1266    }
  1267  
  1268    pageOfTickets (pgNum: number) {
  1269      const { stakeStatus, ticketPage } = this
  1270      let startOffset = pgNum * ticketPageSize
  1271      const pageOfTickets: Ticket[] = []
  1272      if (startOffset < stakeStatus.tickets.length) {
  1273        pageOfTickets.push(...stakeStatus.tickets.slice(startOffset, startOffset + ticketPageSize))
  1274        if (pageOfTickets.length < ticketPageSize) {
  1275          const need = ticketPageSize - pageOfTickets.length
  1276          pageOfTickets.push(...ticketPage.history.slice(0, need))
  1277        }
  1278      } else {
  1279        startOffset -= stakeStatus.tickets.length
  1280        pageOfTickets.push(...ticketPage.history.slice(startOffset, startOffset + ticketPageSize))
  1281      }
  1282      return pageOfTickets
  1283    }
  1284  
  1285    displayTicketPage (pageNumber: number, pageOfTickets: Ticket[]) {
  1286      const { page, selectedAssetID: assetID } = this
  1287      const ui = app().unitInfo(assetID)
  1288      const coinLink = CoinExplorers[assetID][app().user.net]
  1289      Doc.empty(page.ticketHistoryRows)
  1290      page.ticketHistoryPage.textContent = String(pageNumber)
  1291      for (const { tx, status } of pageOfTickets) {
  1292        const tr = page.ticketHistoryRowTmpl.cloneNode(true) as PageElement
  1293        page.ticketHistoryRows.appendChild(tr)
  1294        app().bindUrlHandlers(tr)
  1295        const tmpl = Doc.parseTemplate(tr)
  1296        tmpl.age.textContent = Doc.timeSince(tx.stamp * 1000)
  1297        tmpl.price.textContent = Doc.formatFullPrecision(tx.ticketPrice, ui)
  1298        tmpl.status.textContent = intl.prep(ticketStatusTranslationKeys[status])
  1299        tmpl.hashStart.textContent = tx.hash.slice(0, 6)
  1300        tmpl.hashEnd.textContent = tx.hash.slice(-6)
  1301        tmpl.detailsLinkUrl.setAttribute('href', coinLink(tx.hash))
  1302      }
  1303    }
  1304  
  1305    async ticketPageN (pageNumber: number) {
  1306      const { page, stakeStatus, ticketPage, selectedAssetID: assetID } = this
  1307      const pageOfTickets = this.pageOfTickets(pageNumber)
  1308      if (pageOfTickets.length < ticketPageSize && !ticketPage.scanned) {
  1309        const n = ticketPageSize - pageOfTickets.length
  1310        const lastList = ticketPage.history.length > 0 ? ticketPage.history : stakeStatus.tickets
  1311        const scanStart = lastList.length > 0 ? lastList[lastList.length - 1].tx.blockHeight : scanStartMempool
  1312        const skipN = lastList.filter((tkt: Ticket) => tkt.tx.blockHeight === scanStart).length
  1313        const loaded = app().loading(page.ticketHistoryForm)
  1314        const res = await this.safePost('/api/ticketpage', { assetID, scanStart, n, skipN })
  1315        loaded()
  1316        if (!app().checkResponse(res)) {
  1317          console.error('error fetching ticket page', res.msg)
  1318          return
  1319        }
  1320        this.ticketPage.history.push(...res.tickets)
  1321        pageOfTickets.push(...res.tickets)
  1322        if (res.tickets.length < n) this.ticketPage.scanned = true
  1323      }
  1324  
  1325      const totalTix = stakeStatus.tickets.length + ticketPage.history.length
  1326      Doc.setVis(totalTix >= ticketPageSize, page.ticketHistoryPagination)
  1327      Doc.setVis(totalTix > 0, page.ticketHistoryTable)
  1328      Doc.setVis(totalTix === 0, page.noTicketsMessage)
  1329      if (pageOfTickets.length === 0) {
  1330        // Probably ended with a page of size ticketPageSize, so didn't know we
  1331        // had hit the end until the user clicked the arrow and we went looking
  1332        // for the next. Would be good to figure out a way to hide the arrow in
  1333        // that case.
  1334        Doc.hide(page.ticketHistoryNextPage)
  1335        return
  1336      }
  1337      this.displayTicketPage(pageNumber, pageOfTickets)
  1338      ticketPage.number = pageNumber
  1339      const atEnd = pageNumber * ticketPageSize + pageOfTickets.length === totalTix
  1340      Doc.setVis(!atEnd || !ticketPage.scanned, page.ticketHistoryNextPage)
  1341      Doc.setVis(pageNumber > 0, page.ticketHistoryPrevPage)
  1342    }
  1343  
  1344    async showTicketHistory () {
  1345      this.showForm(this.page.ticketHistoryForm)
  1346      await this.ticketPageN(this.ticketPage.number)
  1347    }
  1348  
  1349    async nextTicketPage () {
  1350      await this.ticketPageN(this.ticketPage.number + 1)
  1351    }
  1352  
  1353    async prevTicketPage () {
  1354      await this.ticketPageN(this.ticketPage.number - 1)
  1355    }
  1356  
  1357    showSetVotesDialog () {
  1358      const { page, stakeStatus, selectedAssetID: assetID } = this
  1359      const ui = app().unitInfo(assetID)
  1360      Doc.hide(page.votingFormErr)
  1361      const coinLink = CoinExplorers[assetID][app().user.net]
  1362      const upperCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
  1363  
  1364      const setVotes = async (req: any) => {
  1365        Doc.hide(page.votingFormErr)
  1366        const loaded = app().loading(page.votingForm)
  1367        const res = await this.safePost('/api/setvotes', req)
  1368        loaded()
  1369        if (!app().checkResponse(res)) {
  1370          Doc.show(page.votingFormErr)
  1371          page.votingFormErr.textContent = res.msg
  1372          throw Error(res.msg)
  1373        }
  1374      }
  1375  
  1376      const setAgendaChoice = async (agendaID: string, choiceID: string) => {
  1377        await setVotes({ assetID, choices: { [agendaID]: choiceID } })
  1378        for (const agenda of stakeStatus.stances.agendas) if (agenda.id === agendaID) agenda.currentChoice = choiceID
  1379      }
  1380  
  1381      Doc.empty(page.votingAgendas)
  1382      for (const agenda of stakeStatus.stances.agendas) {
  1383        const div = page.votingAgendaTmpl.cloneNode(true) as PageElement
  1384        page.votingAgendas.appendChild(div)
  1385        const tmpl = Doc.parseTemplate(div)
  1386        tmpl.description.textContent = agenda.description
  1387        for (const choice of agenda.choices) {
  1388          const div = page.votingChoiceTmpl.cloneNode(true) as PageElement
  1389          tmpl.choices.appendChild(div)
  1390          const choiceTmpl = Doc.parseTemplate(div)
  1391          choiceTmpl.id.textContent = upperCase(choice.id)
  1392          choiceTmpl.id.dataset.tooltip = choice.description
  1393          choiceTmpl.radio.value = choice.id
  1394          choiceTmpl.radio.name = agenda.id
  1395          Doc.bind(choiceTmpl.radio, 'change', () => {
  1396            if (!choiceTmpl.radio.checked) return
  1397            setAgendaChoice(agenda.id, choice.id)
  1398          })
  1399          if (choice.id === agenda.currentChoice) choiceTmpl.radio.checked = true
  1400        }
  1401        app().bindTooltips(tmpl.choices)
  1402      }
  1403  
  1404      const setTspendVote = async (txHash: string, policyID: string) => {
  1405        await setVotes({ assetID, tSpendPolicy: { [txHash]: policyID } })
  1406        for (const tspend of stakeStatus.stances.tspends) if (tspend.hash === txHash) tspend.currentPolicy = policyID
  1407      }
  1408  
  1409      Doc.empty(page.votingTspends)
  1410      for (const tspend of stakeStatus.stances.tspends) {
  1411        const div = page.tspendTmpl.cloneNode(true) as PageElement
  1412        page.votingTspends.appendChild(div)
  1413        app().bindUrlHandlers(div)
  1414        const tmpl = Doc.parseTemplate(div)
  1415        for (const opt of [tmpl.yes, tmpl.no]) {
  1416          opt.name = tspend.hash
  1417          if (tspend.currentPolicy === opt.value) opt.checked = true
  1418          Doc.bind(opt, 'change', () => {
  1419            if (!opt.checked) return
  1420            setTspendVote(tspend.hash, opt.value ?? '')
  1421          })
  1422        }
  1423        if (tspend.value > 0) tmpl.value.textContent = Doc.formatFourSigFigs(tspend.value / ui.conventional.conversionFactor)
  1424        else Doc.hide(tmpl.value)
  1425        tmpl.hash.textContent = tspend.hash
  1426        tmpl.explorerLink.setAttribute('href', coinLink(tspend.hash))
  1427      }
  1428  
  1429      const setTKeyPolicy = async (key: string, policy: string) => {
  1430        await setVotes({ assetID, treasuryPolicy: { [key]: policy } })
  1431        for (const tkey of stakeStatus.stances.treasuryKeys) if (tkey.key === key) tkey.policy = policy
  1432      }
  1433  
  1434      Doc.empty(page.votingTKeys)
  1435      for (const keyPolicy of (stakeStatus.stances.treasuryKeys ?? [])) {
  1436        const div = page.tkeyTmpl.cloneNode(true) as PageElement
  1437        page.votingTKeys.appendChild(div)
  1438        const tmpl = Doc.parseTemplate(div)
  1439        for (const opt of [tmpl.yes, tmpl.no]) {
  1440          opt.name = keyPolicy.key
  1441          if (keyPolicy.policy === opt.value) opt.checked = true
  1442          Doc.bind(opt, 'change', () => {
  1443            if (!opt.checked) return
  1444            setTKeyPolicy(keyPolicy.key, opt.value ?? '')
  1445          })
  1446        }
  1447        tmpl.key.textContent = keyPolicy.key
  1448      }
  1449  
  1450      this.showForm(page.votingForm)
  1451    }
  1452  
  1453    async updatePrivacy (assetID: number) {
  1454      const disablePrivacy = app().extensionWallet(assetID)?.disablePrivacy
  1455      this.mixing = false
  1456      const { wallet } = app().assets[assetID]
  1457      const page = this.page
  1458      Doc.hide(page.mixingBox, page.mixerOff, page.mixerOn)
  1459      // TODO: Show special messaging if the asset supports mixing but not this
  1460      // wallet type.
  1461      if (disablePrivacy || !wallet?.running || (wallet.traits & traitFundsMixer) === 0) return
  1462      Doc.show(page.mixingBox, page.mixerLoading)
  1463      const res = await this.safePost('/api/mixingstats', { assetID })
  1464      Doc.hide(page.mixerLoading)
  1465      if (!app().checkResponse(res)) {
  1466        Doc.show(page.mixingErr)
  1467        page.mixingErr.textContent = res.msg
  1468        return
  1469      }
  1470  
  1471      this.mixing = res.stats.enabled as boolean
  1472      if (this.mixing) Doc.show(page.mixerOn)
  1473      else Doc.show(page.mixerOff)
  1474      this.mixerToggle.setState(this.mixing)
  1475    }
  1476  
  1477    async updateMixerState (on: boolean) {
  1478      const page = this.page
  1479      Doc.hide(page.mixingErr)
  1480      const loaded = app().loading(page.mixingBox)
  1481      const res = await postJSON('/api/configuremixer', { assetID: this.selectedAssetID, enabled: on })
  1482      loaded()
  1483      if (!app().checkResponse(res)) {
  1484        page.mixingErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: res.msg })
  1485        Doc.show(page.mixingErr)
  1486        return
  1487      }
  1488      Doc.setVis(on, page.mixerOn)
  1489      Doc.setVis(!on, page.mixerOff)
  1490      this.mixerToggle.setState(on)
  1491    }
  1492  
  1493    updateDisplayedAssetBalance (): void {
  1494      const page = this.page
  1495      const asset = app().assets[this.selectedAssetID]
  1496      const { wallet, unitInfo: ui, id: assetID } = asset
  1497      const bal = wallet.balance
  1498      Doc.show(page.balanceBox, page.walletDetails)
  1499      const totalLocked = bal.locked + bal.contractlocked + bal.bondlocked
  1500      const totalBalance = bal.available + totalLocked + bal.immature
  1501      page.balance.textContent = Doc.formatCoinValue(totalBalance, ui)
  1502      page.balanceUnit.textContent = ui.conventional.unit
  1503      const rate = app().fiatRatesMap[assetID]
  1504      if (rate) {
  1505        Doc.show(page.fiatBalanceBox)
  1506        page.fiatBalance.textContent = Doc.formatFiatConversion(totalBalance, rate, ui)
  1507      }
  1508      Doc.empty(page.balanceDetailBox)
  1509  
  1510      const addBalanceRow = (cat: string, bal: number, tooltipMsg?: string) => {
  1511        const row = page.balanceDetailRow.cloneNode(true) as PageElement
  1512        page.balanceDetailBox.appendChild(row)
  1513        const tmpl = Doc.parseTemplate(row)
  1514        tmpl.name.textContent = cat
  1515        if (tooltipMsg) {
  1516          tmpl.tooltipMsg.dataset.tooltip = tooltipMsg
  1517          Doc.show(tmpl.tooltipMsg)
  1518        }
  1519        tmpl.balance.textContent = Doc.formatCoinValue(bal, ui)
  1520        return row
  1521      }
  1522  
  1523      let lastSubLockedRow: PageElement | undefined
  1524      let lastPrimaryRow: PageElement | undefined
  1525      const addPrimaryBalance = (cat: string, bal: number, tooltipMsg?: string) => {
  1526        lastSubLockedRow = undefined
  1527        lastPrimaryRow = addBalanceRow(cat, bal, tooltipMsg)
  1528      }
  1529      const addSubBalance = (cat: string, bal: number, tooltipMsg?: string) => {
  1530        lastSubLockedRow = addBalanceRow(cat, bal, tooltipMsg)
  1531        lastSubLockedRow.classList.add('sub')
  1532      }
  1533      const setRowClasses = () => {
  1534        if (!lastSubLockedRow) return
  1535        (lastPrimaryRow as PageElement).classList.add('itemized')
  1536        lastSubLockedRow.classList.add('last')
  1537      }
  1538  
  1539      addPrimaryBalance(intl.prep(intl.ID_AVAILABLE_TITLE), bal.available, '')
  1540      if (bal.other?.Shielded !== undefined) {
  1541        const transparent = bal.available - bal.other.Shielded.amt
  1542        addSubBalance(intl.prep(intl.ID_TRANSPARENT), transparent)
  1543        addSubBalance(intl.prep(intl.ID_SHIELDED), bal.other.Shielded.amt)
  1544      }
  1545      setRowClasses()
  1546  
  1547      addPrimaryBalance(intl.prep(intl.ID_LOCKED_TITLE), totalLocked, intl.prep(intl.ID_LOCKED_BAL_MSG))
  1548      if (bal.orderlocked > 0) addSubBalance(intl.prep(intl.ID_ORDER), bal.orderlocked, intl.prep(intl.ID_LOCKED_ORDER_BAL_MSG))
  1549      if (bal.contractlocked > 0) addSubBalance(intl.prep(intl.ID_SWAPPING), bal.contractlocked, intl.prep(intl.ID_LOCKED_SWAPPING_BAL_MSG))
  1550      if (bal.bondlocked > 0) addSubBalance(intl.prep(intl.ID_BONDED), bal.bondlocked, intl.prep(intl.ID_LOCKED_BOND_BAL_MSG))
  1551      if (bal.bondReserves > 0) addSubBalance(intl.prep(intl.ID_BOND_RESERVES), bal.bondReserves, intl.prep(intl.ID_BOND_RESERVES_MSG))
  1552      if (bal?.other?.Staked !== undefined) addSubBalance('Staked', bal.other.Staked.amt)
  1553      setRowClasses()
  1554  
  1555      if (bal.immature) addPrimaryBalance(intl.prep(intl.ID_IMMATURE_TITLE), bal.immature, intl.prep(intl.ID_IMMATURE_BAL_MSG))
  1556      if (bal?.other?.Unmixed !== undefined) addSubBalance('Unmixed', bal.other.Unmixed.amt)
  1557      setRowClasses()
  1558  
  1559      // TODO: handle reserves deficit with a notification.
  1560      // if (bal.reservesDeficit > 0) addPrimaryBalance(intl.prep(intl.ID_RESERVES_DEFICIT), bal.reservesDeficit, intl.prep(intl.ID_RESERVES_DEFICIT_MSG))
  1561  
  1562      page.purchaserBal.textContent = Doc.formatFourSigFigs(bal.available / ui.conventional.conversionFactor)
  1563      app().bindTooltips(page.balanceDetailBox)
  1564    }
  1565  
  1566    showAvailableMarkets (assetID: number) {
  1567      const page = this.page
  1568      const exchanges = app().user.exchanges
  1569      const markets: [string, Exchange, Market][] = []
  1570      for (const xc of Object.values(exchanges)) {
  1571        if (!xc.markets) continue
  1572        for (const mkt of Object.values(xc.markets)) {
  1573          if (mkt.baseid === assetID || mkt.quoteid === assetID) markets.push([xc.host, xc, mkt])
  1574        }
  1575      }
  1576  
  1577      const spotVolume = (assetID: number, mkt: Market): number => {
  1578        const spot = mkt.spot
  1579        if (!spot) return 0
  1580        const conversionFactor = app().unitInfo(assetID).conventional.conversionFactor
  1581        const volume = assetID === mkt.baseid ? spot.vol24 : spot.vol24 * spot.rate / OrderUtil.RateEncodingFactor
  1582        return volume / conversionFactor
  1583      }
  1584  
  1585      markets.sort((a: [string, Exchange, Market], b: [string, Exchange, Market]): number => {
  1586        const [hostA,, mktA] = a
  1587        const [hostB,, mktB] = b
  1588        if (!mktA.spot && !mktB.spot) return hostA.localeCompare(hostB)
  1589        return spotVolume(assetID, mktB) - spotVolume(assetID, mktA)
  1590      })
  1591      Doc.empty(page.availableMarkets)
  1592  
  1593      for (const [host, xc, mkt] of markets) {
  1594        const { spot, baseid, basesymbol, quoteid, quotesymbol } = mkt
  1595        const row = page.marketRow.cloneNode(true) as PageElement
  1596        page.availableMarkets.appendChild(row)
  1597        const tmpl = Doc.parseTemplate(row)
  1598        tmpl.host.textContent = host
  1599        tmpl.baseLogo.src = Doc.logoPath(basesymbol)
  1600        tmpl.quoteLogo.src = Doc.logoPath(quotesymbol)
  1601        Doc.empty(tmpl.baseSymbol, tmpl.quoteSymbol)
  1602        tmpl.baseSymbol.appendChild(Doc.symbolize(xc.assets[baseid], true))
  1603        tmpl.quoteSymbol.appendChild(Doc.symbolize(xc.assets[quoteid], true))
  1604  
  1605        if (spot) {
  1606          const convRate = app().conventionalRate(baseid, quoteid, spot.rate, exchanges[host])
  1607          tmpl.price.textContent = Doc.formatFourSigFigs(convRate)
  1608          const fmtSymbol = (s: string) => s.split('.')[0].toUpperCase()
  1609          tmpl.priceQuoteUnit.textContent = fmtSymbol(quotesymbol)
  1610          tmpl.priceBaseUnit.textContent = fmtSymbol(basesymbol)
  1611          tmpl.volume.textContent = Doc.formatFourSigFigs(spotVolume(assetID, mkt))
  1612          tmpl.volumeUnit.textContent = assetID === baseid ? fmtSymbol(basesymbol) : fmtSymbol(quotesymbol)
  1613        } else Doc.hide(tmpl.priceBox, tmpl.volumeBox)
  1614        Doc.bind(row, 'click', () => app().loadPage('markets', { host, baseID: baseid, quoteID: quoteid }))
  1615      }
  1616      page.marketsOverviewBox.classList.remove('invisible')
  1617    }
  1618  
  1619    async showRecentActivity (assetID: number) {
  1620      const page = this.page
  1621      const loaded = app().loading(page.orderActivityBox)
  1622      const filter: OrderFilter = {
  1623        n: 20,
  1624        assets: [assetID],
  1625        hosts: [],
  1626        statuses: []
  1627      }
  1628      const res = await postJSON('/api/orders', filter)
  1629      loaded()
  1630      Doc.hide(page.noActivity, page.orderActivity)
  1631      if (!res.orders || res.orders.length === 0) {
  1632        Doc.show(page.noActivity)
  1633        page.orderActivityBox.classList.remove('invisible')
  1634        return
  1635      }
  1636      Doc.show(page.orderActivity)
  1637      Doc.empty(page.recentOrders)
  1638      for (const ord of (res.orders as Order[])) {
  1639        const row = page.recentOrderTmpl.cloneNode(true) as PageElement
  1640        page.recentOrders.appendChild(row)
  1641        const tmpl = Doc.parseTemplate(row)
  1642        let from: SupportedAsset, to: SupportedAsset
  1643        const [baseUnitInfo, quoteUnitInfo] = [app().unitInfo(ord.baseID), app().unitInfo(ord.quoteID)]
  1644        if (ord.sell) {
  1645          [from, to] = [app().assets[ord.baseID], app().assets[ord.quoteID]]
  1646          tmpl.fromQty.textContent = Doc.formatCoinValue(ord.qty, baseUnitInfo)
  1647          if (ord.type === OrderUtil.Limit) {
  1648            tmpl.toQty.textContent = Doc.formatCoinValue(ord.qty / OrderUtil.RateEncodingFactor * ord.rate, quoteUnitInfo)
  1649          }
  1650        } else {
  1651          [from, to] = [app().assets[ord.quoteID], app().assets[ord.baseID]]
  1652          if (ord.type === OrderUtil.Market) {
  1653            tmpl.fromQty.textContent = Doc.formatCoinValue(ord.qty, baseUnitInfo)
  1654          } else {
  1655            tmpl.fromQty.textContent = Doc.formatCoinValue(ord.qty / OrderUtil.RateEncodingFactor * ord.rate, quoteUnitInfo)
  1656            tmpl.toQty.textContent = Doc.formatCoinValue(ord.qty, baseUnitInfo)
  1657          }
  1658        }
  1659  
  1660        tmpl.fromLogo.src = Doc.logoPath(from.symbol)
  1661        Doc.empty(tmpl.fromSymbol, tmpl.toSymbol)
  1662        tmpl.fromSymbol.appendChild(Doc.symbolize(from, true))
  1663        tmpl.toLogo.src = Doc.logoPath(to.symbol)
  1664        tmpl.toSymbol.appendChild(Doc.symbolize(to, true))
  1665        tmpl.status.textContent = OrderUtil.statusString(ord)
  1666        tmpl.filled.textContent = `${(OrderUtil.filled(ord) / ord.qty * 100).toFixed(1)}%`
  1667        tmpl.age.textContent = Doc.timeSince(ord.submitTime)
  1668        tmpl.link.href = `order/${ord.id}`
  1669        app().bindInternalNavigation(row)
  1670      }
  1671      page.orderActivityBox.classList.remove('invisible')
  1672    }
  1673  
  1674    updateTxHistoryRow (row: PageElement, tx: WalletTransaction, assetID: number) {
  1675      const tmpl = Doc.parseTemplate(row)
  1676      let amtAssetID = assetID
  1677      let feesAssetID = assetID
  1678      if (tx.tokenID) {
  1679        amtAssetID = tx.tokenID
  1680        if (assetID !== tx.tokenID) feesAssetID = assetID
  1681        else {
  1682          const asset = app().assets[assetID]
  1683          if (asset.token) feesAssetID = asset.token.parentID
  1684          else console.error(`unable to determine fee asset for tx ${tx.id}`)
  1685        }
  1686      }
  1687      const amtAssetUI = app().unitInfo(amtAssetID)
  1688      const feesAssetUI = app().unitInfo(feesAssetID)
  1689      tmpl.age.textContent = Doc.timeSince(tx.timestamp * 1000)
  1690      tmpl.age.dataset.timestamp = String(tx.timestamp * 1000)
  1691      Doc.setVis(tx.timestamp === 0, tmpl.pending)
  1692      Doc.setVis(tx.timestamp !== 0, tmpl.age)
  1693      if (tx.timestamp > 0) tmpl.age.dataset.stamp = String(tx.timestamp)
  1694      let txType = txTypeString(tx.type)
  1695      if (tx.tokenID && tx.tokenID !== assetID) {
  1696        const tokenAsset = app().assets[tx.tokenID]
  1697        const tokenSymbol = tokenAsset.unitInfo.conventional.unit
  1698        txType = `${tokenSymbol} ${txType}`
  1699      }
  1700      tmpl.type.textContent = txType
  1701      tmpl.id.textContent = trimStringWithEllipsis(tx.id, 12)
  1702      tmpl.id.setAttribute('title', tx.id)
  1703      tmpl.fees.textContent = Doc.formatCoinValue(tx.fees, feesAssetUI)
  1704      if (noAmtTxTypes.includes(tx.type)) {
  1705        tmpl.amount.textContent = '-'
  1706      } else {
  1707        const [u, c] = txTypeSignAndClass(tx.type)
  1708        const amt = Doc.formatCoinValue(tx.amount, amtAssetUI)
  1709        tmpl.amount.textContent = `${u}${amt}`
  1710        if (c !== '') tmpl.amount.classList.add(c)
  1711      }
  1712    }
  1713  
  1714    txHistoryRow (tx: WalletTransaction, assetID: number) : PageElement {
  1715      const row = this.page.txHistoryRowTmpl.cloneNode(true) as PageElement
  1716      row.dataset.txid = tx.id
  1717      Doc.bind(row, 'click', () => this.showTxDetailsPopup(tx.id))
  1718      this.updateTxHistoryRow(row, tx, assetID)
  1719      const tmpl = Doc.parseTemplate(row)
  1720      this.stampers.push(tmpl.age)
  1721      return row
  1722    }
  1723  
  1724    txHistoryDateRow (date: string) : PageElement {
  1725      const row = this.page.txHistoryDateRowTmpl.cloneNode(true) as PageElement
  1726      const tmpl = Doc.parseTemplate(row)
  1727      tmpl.date.textContent = date
  1728      return row
  1729    }
  1730  
  1731    setTxDetailsPopupElements (tx: WalletTransaction) {
  1732      const page = this.page
  1733  
  1734      // Block explorer
  1735      const assetExplorer = CoinExplorers[this.selectedAssetID]
  1736      if (assetExplorer && assetExplorer[net]) {
  1737        page.txViewBlockExplorer.href = assetExplorer[net](tx.id)
  1738      }
  1739  
  1740      // Tx type
  1741      let txType = txTypeString(tx.type)
  1742      if (tx.tokenID && tx.tokenID !== this.selectedAssetID) {
  1743        const tokenSymbol = app().assets[tx.tokenID].symbol.split('.')[0].toUpperCase()
  1744        txType = `${tokenSymbol} ${txType}`
  1745      }
  1746      page.txDetailsType.textContent = txType
  1747      Doc.setVis(tx.type === txTypeSwapOrSend, page.txTypeTooltip)
  1748      page.txTypeTooltip.dataset.tooltip = intl.prep(intl.ID_SWAP_OR_SEND_TOOLTIP)
  1749  
  1750      // Amount
  1751      if (noAmtTxTypes.includes(tx.type)) {
  1752        Doc.hide(page.txDetailsAmtSection)
  1753      } else {
  1754        let assetID = this.selectedAssetID
  1755        if (tx.tokenID) assetID = tx.tokenID
  1756        Doc.show(page.txDetailsAmtSection)
  1757        const ui = app().unitInfo(assetID)
  1758        const amt = Doc.formatCoinValue(tx.amount, ui)
  1759        const [s, c] = txTypeSignAndClass(tx.type)
  1760        page.txDetailsAmount.textContent = `${s}${amt} ${ui.conventional.unit}`
  1761        if (c !== '') page.txDetailsAmount.classList.add(c)
  1762      }
  1763  
  1764      // Fee
  1765      let feeAsset = this.selectedAssetID
  1766      if (tx.tokenID !== undefined) {
  1767        const asset = app().assets[tx.tokenID]
  1768        if (asset.token) {
  1769          feeAsset = asset.token.parentID
  1770        } else {
  1771          console.error(`wallet transaction ${tx.id} is supposed to be a token tx, but asset ${tx.tokenID} is not a token`)
  1772        }
  1773      }
  1774      const feeUI = app().unitInfo(feeAsset)
  1775      const fee = Doc.formatCoinValue(tx.fees, feeUI)
  1776      page.txDetailsFee.textContent = `${fee} ${feeUI.conventional.unit}`
  1777  
  1778      // Time / block number
  1779      page.txDetailsBlockNumber.textContent = `${tx.blockNumber}`
  1780      const date = new Date(tx.timestamp * 1000)
  1781      const dateStr = date.toLocaleDateString()
  1782      const timeStr = date.toLocaleTimeString()
  1783      page.txDetailsTimestamp.textContent = `${dateStr} ${timeStr}`
  1784      Doc.setVis(tx.blockNumber === 0, page.timestampPending, page.blockNumberPending)
  1785      Doc.setVis(tx.blockNumber !== 0, page.txDetailsBlockNumber, page.txDetailsTimestamp)
  1786  
  1787      // Tx ID
  1788      page.txDetailsID.textContent = trimStringWithEllipsis(tx.id, 20)
  1789      page.txDetailsID.setAttribute('title', tx.id)
  1790  
  1791      // Recipient
  1792      if (tx.recipient) {
  1793        Doc.show(page.txDetailsRecipientSection)
  1794        page.txDetailsRecipient.textContent = trimStringWithEllipsis(tx.recipient, 20)
  1795        page.txDetailsRecipient.setAttribute('title', tx.recipient)
  1796      } else {
  1797        Doc.hide(page.txDetailsRecipientSection)
  1798      }
  1799  
  1800      // Bond Info
  1801      if (tx.bondInfo) {
  1802        Doc.show(page.txDetailsBondIDSection, page.txDetailsBondLocktimeSection)
  1803        Doc.setVis(tx.bondInfo.accountID !== '', page.txDetailsBondAccountIDSection)
  1804        page.txDetailsBondID.textContent = trimStringWithEllipsis(tx.bondInfo.bondID, 20)
  1805        page.txDetailsBondID.setAttribute('title', tx.bondInfo.bondID)
  1806        const date = new Date(tx.bondInfo.lockTime * 1000)
  1807        const dateStr = date.toLocaleDateString()
  1808        const timeStr = date.toLocaleTimeString()
  1809        page.txDetailsBondLocktime.textContent = `${dateStr} ${timeStr}`
  1810        page.txDetailsBondAccountID.textContent = trimStringWithEllipsis(tx.bondInfo.accountID, 20)
  1811        page.txDetailsBondAccountID.setAttribute('title', tx.bondInfo.accountID)
  1812      } else {
  1813        Doc.hide(page.txDetailsBondIDSection, page.txDetailsBondLocktimeSection, page.txDetailsBondAccountIDSection)
  1814      }
  1815  
  1816      // Nonce
  1817      if (tx.additionalData && tx.additionalData.Nonce) {
  1818        Doc.show(page.txDetailsNonceSection)
  1819        page.txDetailsNonce.textContent = `${tx.additionalData.Nonce}`
  1820      } else {
  1821        Doc.hide(page.txDetailsNonceSection)
  1822      }
  1823    }
  1824  
  1825    showTxDetailsPopup (id: string) {
  1826      const tx = app().getWalletTx(this.selectedAssetID, id)
  1827      if (!tx) {
  1828        console.error(`wallet transaction ${id} not found`)
  1829        return
  1830      }
  1831      this.currTx = tx
  1832      this.setTxDetailsPopupElements(tx)
  1833      this.showForm(this.page.txDetails)
  1834    }
  1835  
  1836    txHistoryTableNewestDate () : string {
  1837      if (this.page.txHistoryTableBody.children.length >= 1) {
  1838        const tmpl = Doc.parseTemplate(this.page.txHistoryTableBody.children[0] as PageElement)
  1839        return tmpl.date.textContent || ''
  1840      }
  1841      return ''
  1842    }
  1843  
  1844    txDate (tx: WalletTransaction) : string {
  1845      if (tx.timestamp === 0) {
  1846        return (new Date()).toLocaleDateString()
  1847      }
  1848      return (new Date(tx.timestamp * 1000)).toLocaleDateString()
  1849    }
  1850  
  1851    handleTxNote (tx: WalletTransaction, newTx: boolean) {
  1852      const { selectedAssetID: assetID } = this
  1853      this.depositAddrForm.handleTx(assetID, tx)
  1854      const w = app().assets[this.selectedAssetID].wallet
  1855      const hideMixing = (w.traits & traitFundsMixer) !== 0 && !!this.page.hideMixTxs.checked
  1856      if (hideMixing && tx.type === txTypeMixing) return
  1857      if (newTx) {
  1858        if (!this.oldestTx) {
  1859          Doc.show(this.page.txHistoryTable)
  1860          Doc.hide(this.page.noTxHistory)
  1861          this.page.txHistoryTableBody.appendChild(this.txHistoryDateRow(this.txDate(tx)))
  1862          this.page.txHistoryTableBody.appendChild(this.txHistoryRow(tx, assetID))
  1863          this.oldestTx = tx
  1864        } else if (this.txDate(tx) !== this.txHistoryTableNewestDate()) {
  1865          this.page.txHistoryTableBody.insertBefore(this.txHistoryRow(tx, assetID), this.page.txHistoryTableBody.children[0])
  1866          this.page.txHistoryTableBody.insertBefore(this.txHistoryDateRow(this.txDate(tx)), this.page.txHistoryTableBody.children[0])
  1867        } else {
  1868          this.page.txHistoryTableBody.insertBefore(this.txHistoryRow(tx, assetID), this.page.txHistoryTableBody.children[1])
  1869        }
  1870        return
  1871      }
  1872      for (const row of this.page.txHistoryTableBody.children) {
  1873        const peRow = row as PageElement
  1874        if (peRow.dataset.txid === tx.id) {
  1875          this.updateTxHistoryRow(peRow, tx, assetID)
  1876          break
  1877        }
  1878      }
  1879      if (tx.id === this.currTx?.id) {
  1880        this.setTxDetailsPopupElements(tx)
  1881      }
  1882    }
  1883  
  1884    async getTxHistory (assetID: number, hideMixTxs: boolean, after?: string) : Promise<TxHistoryResult> {
  1885      let numToFetch = 10
  1886      if (hideMixTxs) numToFetch = 15
  1887  
  1888      const res : TxHistoryResult = { txs: [], lastTx: false }
  1889      let ref = after
  1890  
  1891      for (let i = 0; i < 40; i++) {
  1892        const currRes = await app().txHistory(assetID, numToFetch, ref)
  1893        if (currRes.txs.length > 0) {
  1894          ref = currRes.txs[currRes.txs.length - 1].id
  1895        }
  1896        let txs = currRes.txs
  1897        if (hideMixTxs) {
  1898          txs = txs.filter((tx) => tx.type !== txTypeMixing)
  1899        }
  1900        if (res.txs.length + txs.length > 10) {
  1901          const numToPush = 10 - res.txs.length
  1902          res.txs.push(...txs.slice(0, numToPush))
  1903        } else {
  1904          if (currRes.lastTx) res.lastTx = true
  1905          res.txs.push(...txs)
  1906        }
  1907        if (res.txs.length >= 10 || currRes.lastTx) break
  1908      }
  1909      return res
  1910    }
  1911  
  1912    async showTxHistory (assetID: number) {
  1913      const page = this.page
  1914      let txRes : TxHistoryResult
  1915      Doc.hide(page.txHistoryTable, page.txHistoryBox, page.noTxHistory, page.earlierTxs, page.txHistoryNotAvailable, page.hideMixTxs)
  1916      Doc.empty(page.txHistoryTableBody)
  1917      const w = app().assets[assetID].wallet
  1918      if (!w || w.disabled || (w.traits & traitHistorian) === 0) {
  1919        Doc.show(page.txHistoryNotAvailable)
  1920        return
  1921      }
  1922  
  1923      this.oldestTx = undefined
  1924  
  1925      const isMixing = (w.traits & traitFundsMixer) !== 0
  1926      Doc.setVis(isMixing, page.hideMixTxs)
  1927      Doc.show(page.txHistoryBox)
  1928  
  1929      try {
  1930        const hideMixing = isMixing && !!page.hideMixTxsCheckbox.checked
  1931        txRes = await this.getTxHistory(assetID, hideMixing)
  1932      } catch (err) {
  1933        Doc.show(page.noTxHistory)
  1934        return
  1935      }
  1936      if (txRes.txs.length === 0) {
  1937        Doc.show(page.noTxHistory)
  1938        return
  1939      }
  1940  
  1941      let oldestDate = this.txDate(txRes.txs[0])
  1942      page.txHistoryTableBody.appendChild(this.txHistoryDateRow(oldestDate))
  1943      for (const tx of txRes.txs) {
  1944        const date = this.txDate(tx)
  1945        if (date !== oldestDate) {
  1946          oldestDate = date
  1947          page.txHistoryTableBody.appendChild(this.txHistoryDateRow(date))
  1948        }
  1949        const row = this.txHistoryRow(tx, assetID)
  1950        page.txHistoryTableBody.appendChild(row)
  1951      }
  1952      this.oldestTx = txRes.txs[txRes.txs.length - 1]
  1953      Doc.show(page.txHistoryTable)
  1954      Doc.setVis(!txRes.lastTx, page.earlierTxs)
  1955    }
  1956  
  1957    async loadEarlierTxs () {
  1958      if (!this.oldestTx) return
  1959      const page = this.page
  1960      let txRes : TxHistoryResult
  1961      const w = app().assets[this.selectedAssetID].wallet
  1962      const hideMixing = (w.traits & traitFundsMixer) !== 0 && !!page.hideMixTxsCheckbox.checked
  1963      try {
  1964        txRes = await this.getTxHistory(this.selectedAssetID, hideMixing, this.oldestTx.id)
  1965      } catch (err) {
  1966        console.error(err)
  1967        return
  1968      }
  1969      let oldestDate = this.txDate(this.oldestTx)
  1970      for (const tx of txRes.txs) {
  1971        const date = this.txDate(tx)
  1972        if (date !== oldestDate) {
  1973          oldestDate = date
  1974          page.txHistoryTableBody.appendChild(this.txHistoryDateRow(date))
  1975        }
  1976        const row = this.txHistoryRow(tx, this.selectedAssetID)
  1977        page.txHistoryTableBody.appendChild(row)
  1978      }
  1979      Doc.setVis(!txRes.lastTx, page.earlierTxs)
  1980      if (txRes.txs.length > 0) {
  1981        this.oldestTx = txRes.txs[txRes.txs.length - 1]
  1982      }
  1983    }
  1984  
  1985    async rescanWallet (assetID: number) {
  1986      const page = this.page
  1987      Doc.hide(page.reconfigErr)
  1988  
  1989      const url = '/api/rescanwallet'
  1990      const req = { assetID: assetID }
  1991  
  1992      const loaded = app().loading(this.body)
  1993      const res = await postJSON(url, req)
  1994      loaded()
  1995      if (res.code === Errors.activeOrdersErr) {
  1996        this.forceUrl = url
  1997        this.forceReq = req
  1998        this.showConfirmForce()
  1999        return
  2000      }
  2001      if (!app().checkResponse(res)) {
  2002        Doc.showFormError(page.reconfigErr, res.msg)
  2003        return
  2004      }
  2005      this.assetUpdated(assetID, page.reconfigForm, intl.prep(intl.ID_RESCAN_STARTED))
  2006    }
  2007  
  2008    showConfirmForce () {
  2009      Doc.hide(this.page.confirmForceErr)
  2010      this.showForm(this.page.confirmForce)
  2011    }
  2012  
  2013    showRecoverWallet () {
  2014      Doc.hide(this.page.recoverWalletErr)
  2015      this.showForm(this.page.recoverWalletConfirm)
  2016    }
  2017  
  2018    /* Show the open wallet form if the password is not cached, and otherwise
  2019     * attempt to open the wallet.
  2020     */
  2021    async openWallet (assetID: number) {
  2022      const open = {
  2023        assetID: assetID
  2024      }
  2025      const res = await postJSON('/api/openwallet', open)
  2026      if (!app().checkResponse(res)) {
  2027        console.error('openwallet error', res)
  2028        return
  2029      }
  2030      this.assetUpdated(assetID, undefined, intl.prep(intl.ID_WALLET_UNLOCKED))
  2031    }
  2032  
  2033    /* Show the form used to change wallet configuration settings. */
  2034    async showReconfig (assetID: number, cfg?: reconfigSettings) {
  2035      const page = this.page
  2036      Doc.hide(
  2037        page.changeWalletType, page.changeTypeHideIcon, page.reconfigErr,
  2038        page.showChangeType, page.changeTypeHideIcon, page.reconfigErr,
  2039        page.enableWallet, page.disableWallet
  2040      )
  2041      // Hide update password section by default
  2042      this.changeWalletPW = false
  2043      this.setPWSettingViz(this.changeWalletPW)
  2044      const asset = app().assets[assetID]
  2045  
  2046      const currentDef = app().currentWalletDefinition(assetID)
  2047      const walletDefs = asset.token ? [asset.token.definition] : asset.info ? asset.info.availablewallets : []
  2048      const disableWalletType = app().extensionWallet(assetID)?.disableWalletType
  2049      if (walletDefs.length > 1 && !disableWalletType) {
  2050        Doc.empty(page.changeWalletTypeSelect)
  2051        Doc.show(page.showChangeType, page.changeTypeShowIcon)
  2052        page.changeTypeMsg.textContent = intl.prep(intl.ID_CHANGE_WALLET_TYPE)
  2053        for (const wDef of walletDefs) {
  2054          const option = document.createElement('option') as HTMLOptionElement
  2055          if (wDef.type === currentDef.type) option.selected = true
  2056          option.value = option.textContent = wDef.type
  2057          page.changeWalletTypeSelect.appendChild(option)
  2058        }
  2059      }
  2060  
  2061      if (cfg?.elevateProviders) {
  2062        for (const opt of (currentDef.configopts)) if (opt.key === 'providers') opt.required = true
  2063      }
  2064  
  2065      const wallet = app().walletMap[assetID]
  2066      Doc.setVis(wallet.traits & traitLogFiler, page.downloadLogs)
  2067      Doc.setVis(wallet.traits & traitRecoverer, page.recoverWallet)
  2068      Doc.setVis(wallet.traits & traitRestorer, page.exportWallet)
  2069      Doc.setVis(wallet.traits & traitRescanner, page.rescanWallet)
  2070      Doc.setVis(wallet.traits & traitPeerManager && !wallet.disabled, page.managePeers)
  2071      Doc.setVis(wallet.traits & traitTokenApprover && !wallet.disabled, page.unapproveTokenAllowance)
  2072  
  2073      Doc.setVis(wallet.traits & traitsExtraOpts, page.otherActionsLabel)
  2074  
  2075      if (wallet.disabled) Doc.show(page.enableWallet)
  2076      else Doc.show(page.disableWallet)
  2077  
  2078      this.showOrHideRecoverySupportMsg(wallet, currentDef.seeded)
  2079  
  2080      page.recfgAssetLogo.src = Doc.logoPath(asset.symbol)
  2081      page.recfgAssetName.textContent = asset.name
  2082      if (!cfg?.skipAnimation) this.showForm(page.reconfigForm)
  2083      const loaded = app().loading(page.reconfigForm)
  2084      const res = await postJSON('/api/walletsettings', { assetID })
  2085      loaded()
  2086      if (!app().checkResponse(res)) {
  2087        Doc.showFormError(page.reconfigErr, res.msg)
  2088        return
  2089      }
  2090      const assetHasActiveOrders = app().haveActiveOrders(assetID)
  2091      this.reconfigForm.update(asset.id, currentDef.configopts || [], assetHasActiveOrders)
  2092      this.setGuideLink(currentDef.guidelink)
  2093      this.reconfigForm.setConfig(res.map)
  2094      this.updateDisplayedReconfigFields(currentDef)
  2095    }
  2096  
  2097    showOrHideRecoverySupportMsg (wallet: WalletState, seeded: boolean) {
  2098      this.setRecoverySupportMsgViz(seeded && !wallet.running && !wallet.disabled && Boolean(wallet.traits & traitRecoverer), wallet.symbol)
  2099    }
  2100  
  2101    setRecoverySupportMsgViz (viz: boolean, symbol: string) {
  2102      const page = this.page
  2103      if (viz) {
  2104        page.reconfigSupportMsg.textContent = intl.prep(intl.ID_WALLET_RECOVERY_SUPPORT_MSG, { walletSymbol: symbol.toLocaleUpperCase() })
  2105        Doc.show(page.reconfigSupportMsg)
  2106        page.submitReconfig.setAttribute('disabled', '')
  2107        page.submitReconfig.classList.add('grey')
  2108        return
  2109      }
  2110      page.submitReconfig.removeAttribute('disabled')
  2111      page.submitReconfig.classList.remove('grey')
  2112      Doc.empty(page.reconfigSupportMsg)
  2113      Doc.hide(page.reconfigSupportMsg)
  2114    }
  2115  
  2116    changeWalletType () {
  2117      const page = this.page
  2118      const walletType = page.changeWalletTypeSelect.value || ''
  2119      const walletDef = app().walletDefinition(this.selectedAssetID, walletType)
  2120      this.reconfigForm.update(this.selectedAssetID, walletDef.configopts || [], false)
  2121      const wallet = app().walletMap[this.selectedAssetID]
  2122      const currentDef = app().currentWalletDefinition(this.selectedAssetID)
  2123      if (walletDef.type !== currentDef.type) this.setRecoverySupportMsgViz(false, wallet.symbol)
  2124      else this.showOrHideRecoverySupportMsg(wallet, walletDef.seeded)
  2125      this.setGuideLink(walletDef.guidelink)
  2126      this.updateDisplayedReconfigFields(walletDef)
  2127    }
  2128  
  2129    setGuideLink (guideLink: string) {
  2130      Doc.hide(this.walletCfgGuide)
  2131      if (guideLink !== '') {
  2132        this.walletCfgGuide.href = guideLink
  2133        Doc.show(this.walletCfgGuide)
  2134      }
  2135    }
  2136  
  2137    updateDisplayedReconfigFields (walletDef: WalletDefinition) {
  2138      const disablePassword = app().extensionWallet(this.selectedAssetID)?.disablePassword
  2139      if (walletDef.seeded || walletDef.type === 'token' || disablePassword) {
  2140        Doc.hide(this.page.showChangePW, this.reconfigForm.fileSelector)
  2141        this.changeWalletPW = false
  2142        this.setPWSettingViz(false)
  2143      } else Doc.show(this.page.showChangePW, this.reconfigForm.fileSelector)
  2144    }
  2145  
  2146    /* Display a deposit address. */
  2147    async showDeposit (assetID: number) {
  2148      this.depositAddrForm.setAsset(assetID)
  2149      this.showForm(this.page.deposit)
  2150    }
  2151  
  2152    /* Show the form to either send or withdraw funds. */
  2153    async showSendForm (assetID: number) {
  2154      const page = this.page
  2155      const box = page.sendForm
  2156      const { wallet, unitInfo: ui, symbol, token } = app().assets[assetID]
  2157      Doc.hide(page.toggleSubtract)
  2158      page.subtractCheckBox.checked = false
  2159  
  2160      const isWithdrawer = (wallet.traits & traitWithdrawer) !== 0
  2161      if (isWithdrawer) {
  2162        Doc.show(page.toggleSubtract)
  2163      }
  2164  
  2165      Doc.hide(page.sendErr, page.maxSendDisplay, page.sendTokenMsgBox)
  2166      page.sendAddr.classList.remove('border-danger', 'border-success')
  2167      page.sendAddr.value = ''
  2168      page.sendAmt.value = ''
  2169      const xcRate = app().fiatRatesMap[assetID]
  2170      Doc.showFiatValue(page.sendValue, 0, xcRate, ui)
  2171      page.walletBal.textContent = Doc.formatFullPrecision(wallet.balance.available, ui)
  2172      page.sendLogo.src = Doc.logoPath(symbol)
  2173      page.sendName.textContent = ui.conventional.unit
  2174      if (token) {
  2175        const parentAsset = app().assets[token.parentID]
  2176        page.sendTokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
  2177        page.sendTokenParentName.textContent = parentAsset.name
  2178        Doc.show(page.sendTokenMsgBox)
  2179      }
  2180      // page.sendFee.textContent = wallet.feerate
  2181      // page.sendUnit.textContent = wallet.units
  2182  
  2183      if (wallet.balance.available > 0 && (wallet.traits & traitTxFeeEstimator) !== 0) {
  2184        const feeReq = {
  2185          assetID: assetID,
  2186          subtract: isWithdrawer,
  2187          maxWithdraw: true,
  2188          value: wallet.balance.available
  2189        }
  2190  
  2191        const loaded = app().loading(this.body)
  2192        const res = await postJSON('/api/txfee', feeReq)
  2193        loaded()
  2194        if (app().checkResponse(res)) {
  2195          let canSend = wallet.balance.available
  2196          if (!token) {
  2197            canSend -= res.txfee
  2198            if (canSend < 0) canSend = 0
  2199          }
  2200  
  2201          this.maxSend = canSend
  2202          page.maxSend.textContent = Doc.formatFullPrecision(canSend, ui)
  2203          Doc.showFiatValue(page.maxSendFiat, canSend, xcRate, ui)
  2204          if (token) {
  2205            const feeUI = app().assets[token.parentID].unitInfo
  2206            page.maxSendFee.textContent = Doc.formatFullPrecision(res.txfee, feeUI) + ' ' + feeUI.conventional.unit
  2207            Doc.showFiatValue(page.maxSendFeeFiat, res.txfee, app().fiatRatesMap[token.parentID], feeUI)
  2208          } else {
  2209            page.maxSendFee.textContent = Doc.formatFullPrecision(res.txfee, ui)
  2210            Doc.showFiatValue(page.maxSendFeeFiat, res.txfee, xcRate, ui)
  2211          }
  2212          Doc.show(page.maxSendDisplay)
  2213        }
  2214      }
  2215  
  2216      Doc.showFiatValue(page.sendValue, 0, xcRate, ui)
  2217      page.walletBal.textContent = Doc.formatFullPrecision(wallet.balance.available, ui)
  2218      box.dataset.assetID = String(assetID)
  2219      this.showForm(box)
  2220    }
  2221  
  2222    /* doConnect connects to a wallet via the connectwallet API route. */
  2223    async doConnect (assetID: number) {
  2224      const loaded = app().loading(this.body)
  2225      const res = await postJSON('/api/connectwallet', { assetID })
  2226      loaded()
  2227      if (!app().checkResponse(res)) {
  2228        const { symbol } = app().assets[assetID]
  2229        const page = this.page
  2230        page.errorModalMsg.textContent = intl.prep(intl.ID_CONNECT_WALLET_ERR_MSG, { assetName: symbol, errMsg: res.msg })
  2231        this.showForm(page.errorModal)
  2232      }
  2233      this.updateDisplayedAsset(assetID)
  2234    }
  2235  
  2236    assetUpdated (assetID: number, oldForm?: PageElement, successMsg?: string) {
  2237      if (assetID !== this.selectedAssetID) return
  2238      this.updateDisplayedAsset(assetID)
  2239      if (oldForm && Object.is(this.currentForm, oldForm)) {
  2240        if (successMsg) this.showSuccess(successMsg)
  2241        else this.closePopups()
  2242      }
  2243    }
  2244  
  2245    /* populateMaxSend populates the amount field with the max amount the wallet
  2246       can send. The max send amount can be the maximum amount based on our
  2247       pre-estimation or the asset's wallet balance.
  2248    */
  2249    async populateMaxSend () {
  2250      const page = this.page
  2251      const { id: assetID, unitInfo: ui, wallet } = app().assets[this.selectedAssetID]
  2252      // Populate send amount with max send value and ensure we don't check
  2253      // subtract checkbox for assets that don't have a withdraw method.
  2254      const xcRate = app().fiatRatesMap[assetID]
  2255      if ((wallet.traits & traitWithdrawer) === 0) {
  2256        page.sendAmt.value = String(this.maxSend / ui.conventional.conversionFactor)
  2257        Doc.showFiatValue(page.sendValue, this.maxSend, xcRate, ui)
  2258        page.subtractCheckBox.checked = false
  2259      } else {
  2260        const amt = wallet.balance.available
  2261        page.sendAmt.value = String(amt / ui.conventional.conversionFactor)
  2262        Doc.showFiatValue(page.sendValue, amt, xcRate, ui)
  2263        page.subtractCheckBox.checked = true
  2264      }
  2265    }
  2266  
  2267    /* send submits the send form to the API. */
  2268    async send (): Promise<void> {
  2269      const page = this.page
  2270      const assetID = parseInt(page.sendForm.dataset.assetID ?? '')
  2271      const subtract = page.subtractCheckBox.checked ?? false
  2272      const conversionFactor = app().unitInfo(assetID).conventional.conversionFactor
  2273      const pw = page.vSendPw.value || ''
  2274      page.vSendPw.value = ''
  2275      if (pw === '') {
  2276        Doc.showFormError(page.vSendErr, intl.prep(intl.ID_NO_PASS_ERROR_MSG))
  2277        return
  2278      }
  2279      const open = {
  2280        assetID: assetID,
  2281        address: page.sendAddr.value,
  2282        subtract: subtract,
  2283        value: Math.round(parseFloatDefault(page.sendAmt.value) * conversionFactor),
  2284        pw: pw
  2285      }
  2286      const loaded = app().loading(page.vSendForm)
  2287      const res = await postJSON('/api/send', open)
  2288      loaded()
  2289      if (!app().checkResponse(res)) {
  2290        Doc.showFormError(page.vSendErr, res.msg)
  2291        return
  2292      }
  2293      const name = app().assets[assetID].name
  2294      this.assetUpdated(assetID, page.vSendForm, intl.prep(intl.ID_SEND_SUCCESS, { assetName: name }))
  2295    }
  2296  
  2297    /* update wallet configuration */
  2298    async reconfig (): Promise<void> {
  2299      const page = this.page
  2300      const assetID = this.selectedAssetID
  2301      Doc.hide(page.reconfigErr)
  2302      let walletType = app().currentWalletDefinition(assetID).type
  2303      if (!Doc.isHidden(page.changeWalletType)) {
  2304        walletType = page.changeWalletTypeSelect.value || ''
  2305      }
  2306  
  2307      const loaded = app().loading(page.reconfigForm)
  2308      const req: ReconfigRequest = {
  2309        assetID: assetID,
  2310        config: this.reconfigForm.map(assetID),
  2311        walletType: walletType
  2312      }
  2313      if (this.changeWalletPW) req.newWalletPW = page.newPW.value
  2314      const res = await this.safePost('/api/reconfigurewallet', req)
  2315      page.newPW.value = ''
  2316      loaded()
  2317      if (!app().checkResponse(res)) {
  2318        Doc.showFormError(page.reconfigErr, res.msg)
  2319        return
  2320      }
  2321      if (this.data?.goBack) {
  2322        app().loadPage(this.data.goBack)
  2323        return
  2324      }
  2325      this.assetUpdated(assetID, page.reconfigForm, intl.prep(intl.ID_RECONFIG_SUCCESS))
  2326      this.updateTicketBuyer(assetID)
  2327      app().clearTxHistory(assetID)
  2328      this.showTxHistory(assetID)
  2329      this.updatePrivacy(assetID)
  2330      this.checkNeedsProvider(assetID)
  2331    }
  2332  
  2333    /* lock instructs the API to lock the wallet. */
  2334    async lock (assetID: number): Promise<void> {
  2335      const page = this.page
  2336      const loaded = app().loading(page.newWalletForm)
  2337      const res = await postJSON('/api/closewallet', { assetID: assetID })
  2338      loaded()
  2339      if (!app().checkResponse(res)) return
  2340      this.updateDisplayedAsset(assetID)
  2341      this.updatePrivacy(assetID)
  2342    }
  2343  
  2344    async downloadLogs (): Promise<void> {
  2345      const search = new URLSearchParams('')
  2346      search.append('assetid', `${this.selectedAssetID}`)
  2347      const url = new URL(window.location.href)
  2348      url.search = search.toString()
  2349      url.pathname = '/wallets/logfile'
  2350      window.open(url.toString())
  2351    }
  2352  
  2353    // displayExportWalletAuth displays a form to warn the user about the
  2354    // dangers of exporting a wallet, and asks them to enter their password.
  2355    async displayExportWalletAuth (): Promise<void> {
  2356      const page = this.page
  2357      Doc.hide(page.exportWalletErr)
  2358      page.exportWalletPW.value = ''
  2359      this.showForm(page.exportWalletAuth)
  2360    }
  2361  
  2362    // exportWalletAuthSubmit is called after the user enters their password to
  2363    // authorize looking up the information to restore their wallet in an
  2364    // external wallet.
  2365    async exportWalletAuthSubmit (): Promise<void> {
  2366      const page = this.page
  2367      const req = {
  2368        assetID: this.selectedAssetID,
  2369        pass: page.exportWalletPW.value
  2370      }
  2371      const url = '/api/restorewalletinfo'
  2372      const loaded = app().loading(page.forms)
  2373      const res = await postJSON(url, req)
  2374      loaded()
  2375      if (app().checkResponse(res)) {
  2376        page.exportWalletPW.value = ''
  2377        this.displayRestoreWalletInfo(res.restorationinfo)
  2378      } else {
  2379        Doc.showFormError(page.exportWalletErr, res.msg)
  2380      }
  2381    }
  2382  
  2383    // displayRestoreWalletInfo displays the information needed to restore a
  2384    // wallet in external wallets.
  2385    async displayRestoreWalletInfo (info: WalletRestoration[]): Promise<void> {
  2386      const page = this.page
  2387      Doc.empty(page.restoreInfoCardsList)
  2388      for (const wr of info) {
  2389        const card = this.restoreInfoCard.cloneNode(true) as HTMLElement
  2390        const tmpl = Doc.parseTemplate(card)
  2391        tmpl.name.textContent = wr.target
  2392        tmpl.seed.textContent = wr.seed
  2393        tmpl.seedName.textContent = `${wr.seedName}:`
  2394        tmpl.instructions.textContent = wr.instructions
  2395        page.restoreInfoCardsList.appendChild(card)
  2396      }
  2397      this.showForm(page.restoreWalletInfo)
  2398    }
  2399  
  2400    async recoverWallet (): Promise<void> {
  2401      const page = this.page
  2402      Doc.hide(page.recoverWalletErr)
  2403      const req = {
  2404        assetID: this.selectedAssetID
  2405      }
  2406      const url = '/api/recoverwallet'
  2407      const loaded = app().loading(page.forms)
  2408      const res = await postJSON(url, req)
  2409      loaded()
  2410      if (res.code === Errors.activeOrdersErr) {
  2411        this.forceUrl = url
  2412        this.forceReq = req
  2413        this.showConfirmForce()
  2414      } else if (app().checkResponse(res)) {
  2415        this.closePopups()
  2416      } else {
  2417        Doc.showFormError(page.recoverWalletErr, res.msg)
  2418      }
  2419    }
  2420  
  2421    /*
  2422     * confirmForceSubmit resubmits either the recover or rescan requests with
  2423     * force set to true. These two requests require force to be set to true if
  2424     * they are called while the wallet is managing active orders.
  2425     */
  2426    async confirmForceSubmit (): Promise<void> {
  2427      const page = this.page
  2428      this.forceReq.force = true
  2429      const loaded = app().loading(page.forms)
  2430      const res = await postJSON(this.forceUrl, this.forceReq)
  2431      loaded()
  2432      if (app().checkResponse(res)) this.closePopups()
  2433      else {
  2434        Doc.showFormError(page.confirmForceErr, res.msg)
  2435      }
  2436    }
  2437  
  2438    /* handleBalance handles notifications updating a wallet's balance and assets'
  2439       value in default fiat rate.
  2440    . */
  2441    handleBalanceNote (note: BalanceNote): void {
  2442      this.updateAssetButton(note.assetID)
  2443      if (note.assetID === this.selectedAssetID) this.updateDisplayedAssetBalance()
  2444    }
  2445  
  2446    /* handleRatesNote handles fiat rate notifications, updating the fiat value of
  2447     *  all supported assets.
  2448     */
  2449    handleRatesNote (note: RateNote): void {
  2450      this.updateAssetButton(this.selectedAssetID)
  2451      if (!note.fiatRates[this.selectedAssetID]) return
  2452      this.updateDisplayedAssetBalance()
  2453      const { feeState } = app().walletMap[this.selectedAssetID]
  2454      if (feeState) this.updateFeeState(feeState)
  2455    }
  2456  
  2457    /*
  2458     * handleWalletStateNote is a handler for both the 'walletstate' and
  2459     * 'walletconfig' notifications.
  2460     */
  2461    handleWalletStateNote (note: WalletStateNote): void {
  2462      const { assetID, feeState } = note.wallet
  2463      this.updateAssetButton(assetID)
  2464      this.assetUpdated(assetID)
  2465      if (note.topic === 'WalletPeersUpdate' &&
  2466          assetID === this.selectedAssetID &&
  2467          Doc.isDisplayed(this.page.managePeersForm)) {
  2468        this.updateWalletPeersTable()
  2469      }
  2470      if (feeState && assetID === this.selectedAssetID) this.updateFeeState(feeState)
  2471    }
  2472  
  2473    /*
  2474     * handleCreateWalletNote is a handler for 'createwallet' notifications.
  2475     */
  2476    handleCreateWalletNote (note: WalletCreationNote) {
  2477      this.updateAssetButton(note.assetID)
  2478      this.assetUpdated(note.assetID)
  2479      this.showTxHistory(note.assetID)
  2480    }
  2481  
  2482    handleCustomWalletNote (note: WalletNote) {
  2483      const walletNote = note.payload as BaseWalletNote
  2484      switch (walletNote.route) {
  2485        case 'tipChange': {
  2486          const n = walletNote as TipChangeNote
  2487          switch (n.assetID) {
  2488            case 42: { // dcr
  2489              if (!this.stakeStatus) return
  2490              const data = n.data as DecredTicketTipUpdate
  2491              const synced = app().walletMap[n.assetID].synced
  2492              if (synced) {
  2493                const ui = app().unitInfo(n.assetID)
  2494                this.updateTicketStats(data.stats, ui, data.ticketPrice, data.votingSubsidy)
  2495              }
  2496            }
  2497          }
  2498          break
  2499        }
  2500        case 'ticketPurchaseUpdate': {
  2501          this.processTicketPurchaseUpdate(walletNote as CustomWalletNote)
  2502          break
  2503        }
  2504        case 'transaction': {
  2505          const n = walletNote as TransactionNote
  2506          if (n.assetID === this.selectedAssetID) this.handleTxNote(n.transaction, n.new)
  2507          break
  2508        }
  2509        case 'transactionHistorySynced' : {
  2510          const n = walletNote
  2511          if (n.assetID === this.selectedAssetID) this.showTxHistory(n.assetID)
  2512          break
  2513        }
  2514      }
  2515    }
  2516  
  2517    /*
  2518     * unload is called by the Application when the user navigates away from
  2519     * the /wallets page.
  2520     */
  2521    unload (): void {
  2522      clearInterval(this.secondTicker)
  2523      Doc.unbind(document, 'keyup', this.keyup)
  2524    }
  2525  }
  2526  
  2527  function trimStringWithEllipsis (str: string, maxLen: number): string {
  2528    if (str.length <= maxLen) return str
  2529    return `${str.substring(0, maxLen / 2)}...${str.substring(str.length - maxLen / 2)}`
  2530  }