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

     1  import Doc, { Animation } from './doc'
     2  import { postJSON } from './http'
     3  import State from './state'
     4  import * as intl from './locales'
     5  import { Wave } from './charts'
     6  import {
     7    bondReserveMultiplier,
     8    perTierBaseParcelLimit,
     9    parcelLimitScoreMultiplier,
    10    strongTier
    11  } from './account'
    12  import {
    13    app,
    14    SupportedAsset,
    15    PageElement,
    16    WalletDefinition,
    17    ConfigOption,
    18    Exchange,
    19    Market,
    20    BondAsset,
    21    WalletState,
    22    BalanceNote,
    23    Order,
    24    XYRange,
    25    WalletStateNote,
    26    WalletSyncNote,
    27    WalletInfo,
    28    Token,
    29    WalletCreationNote,
    30    CoreNote,
    31    PrepaidBondID
    32  } from './registry'
    33  import { XYRangeHandler } from './opts'
    34  import { CoinExplorers } from './coinexplorers'
    35  import { MM, setCexElements } from './mmutil'
    36  
    37  interface ConfigOptionInput extends HTMLInputElement {
    38    configOpt: ConfigOption
    39  }
    40  
    41  interface ProgressPoint {
    42    stamp: number
    43    progress: number
    44  }
    45  
    46  interface CurrentAsset {
    47    asset: SupportedAsset
    48    parentAsset?: SupportedAsset
    49    winfo: WalletInfo | Token
    50    // selectedDef is used in a strange way for tokens. If a token's parent wallet
    51    // already exists, then selectedDef is going to be the Token.definition.
    52    // BUT, if the token's parent wallet doesn't exist yet, the NewWalletForm
    53    // operates in a combined configuration mode, and the selectedDef will be the
    54    // currently selected parent asset definition. There is no loss of info
    55    // in such a case, because the token wallet only has one definition.
    56    selectedDef: WalletDefinition
    57  }
    58  
    59  interface WalletConfig {
    60    assetID: number
    61    config: Record<string, string>
    62    walletType: string
    63  }
    64  
    65  interface FormsConfig {
    66    closed?: (closedForm: PageElement | undefined) => void
    67  }
    68  
    69  export class Forms {
    70    formsDiv: PageElement
    71    currentForm: PageElement | undefined
    72    currentFormID: string | undefined
    73    keyup: (e: KeyboardEvent) => void
    74    closed?: (closedForm: PageElement | undefined) => void
    75  
    76    constructor (formsDiv: PageElement, cfg?: FormsConfig) {
    77      this.formsDiv = formsDiv
    78      this.closed = cfg?.closed
    79  
    80      formsDiv.querySelectorAll('.form-closer').forEach(el => {
    81        Doc.bind(el, 'click', () => { this.close() })
    82      })
    83  
    84      Doc.bind(formsDiv, 'mousedown', (e: MouseEvent) => {
    85        if (!this.currentForm) return
    86        if (!Doc.mouseInElement(e, this.currentForm)) { this.close() }
    87      })
    88  
    89      this.keyup = (e: KeyboardEvent) => {
    90        if (e.key === 'Escape') {
    91          this.close()
    92        }
    93      }
    94      Doc.bind(document, 'keyup', this.keyup)
    95    }
    96  
    97    /* showForm shows a modal form with a little animation. */
    98    async show (form: HTMLElement, id?: string): Promise<void> {
    99      this.currentForm = form
   100      this.currentFormID = id
   101      Doc.hide(...Array.from(this.formsDiv.children))
   102      form.style.right = '10000px'
   103      Doc.show(this.formsDiv, form)
   104      const shift = (this.formsDiv.offsetWidth + form.offsetWidth) / 2
   105      await Doc.animate(animationLength, progress => {
   106        form.style.right = `${(1 - progress) * shift}px`
   107      }, 'easeOutHard')
   108      form.style.right = '0'
   109    }
   110  
   111    close (): void {
   112      Doc.hide(this.formsDiv)
   113      const closedForm = this.currentForm
   114      this.currentForm = undefined
   115      this.currentFormID = undefined
   116      if (this.closed) this.closed(closedForm)
   117    }
   118  
   119    exit () {
   120      Doc.unbind(document, 'keyup', this.keyup)
   121    }
   122  }
   123  
   124  /*
   125   * NewWalletForm should be used with the "newWalletForm" template. The enclosing
   126   * <form> element should be the first argument of the constructor.
   127   */
   128  export class NewWalletForm {
   129    page: Record<string, PageElement>
   130    form: HTMLElement
   131    success: (assetID: number) => void
   132    current: CurrentAsset
   133    subform: WalletConfigForm
   134    walletCfgGuide: PageElement
   135    parentSyncer: null | ((w: WalletState) => void)
   136    createUpdater: null | ((note: WalletCreationNote) => void)
   137  
   138    constructor (form: HTMLElement, success: (assetID: number) => void, backFunc?: () => void) {
   139      this.form = form
   140      this.success = success
   141      const page = this.page = Doc.parseTemplate(form)
   142  
   143      if (backFunc) {
   144        Doc.show(page.goBack)
   145        Doc.bind(page.goBack, 'click', () => { backFunc() })
   146      }
   147  
   148      Doc.empty(page.walletTabTmpl)
   149      page.walletTabTmpl.removeAttribute('id')
   150  
   151      // WalletConfigForm will set the global app variable.
   152      this.subform = new WalletConfigForm(page.walletSettings, true)
   153  
   154      this.walletCfgGuide = Doc.tmplElement(form, 'walletCfgGuide')
   155  
   156      bind(form, page.submitAdd, () => this.submit())
   157      bind(form, page.oneBttn, () => this.submit())
   158  
   159      app().registerNoteFeeder({
   160        walletstate: (note: WalletStateNote) => { this.reportWalletState(note.wallet) },
   161        walletsync: (note: WalletSyncNote) => { if (this.parentSyncer) this.parentSyncer(app().walletMap[note.assetID]) },
   162        createwallet: (note: WalletCreationNote) => { this.reportCreationUpdate(note) }
   163      })
   164    }
   165  
   166    /*
   167     * reportWalletState should be called when a 'walletstate' notification is
   168     * received.
   169     * TODO: Let form classes register for notifications.
   170     */
   171    reportWalletState (w: WalletState): void {
   172      if (this.parentSyncer) this.parentSyncer(w)
   173    }
   174  
   175    /*
   176     * reportWalletState should be called when a 'createwallet' notification is
   177     * received.
   178     */
   179    reportCreationUpdate (note: WalletCreationNote) {
   180      if (this.createUpdater) this.createUpdater(note)
   181    }
   182  
   183    async createWallet (assetID: number, walletType: string, parentForm?: WalletConfig) {
   184      const createForm = {
   185        assetID: assetID,
   186        pass: this.page.newWalletPass.value || '',
   187        config: this.subform.map(assetID),
   188        walletType: walletType,
   189        parentForm: parentForm
   190      }
   191  
   192      const ani = new Wave(this.form, { backgroundColor: true })
   193      const res = await postJSON('/api/newwallet', createForm)
   194      ani.stop()
   195      return res
   196    }
   197  
   198    async submit () {
   199      const page = this.page
   200      const newWalletPass = page.newWalletPass as HTMLInputElement
   201      Doc.hide(page.newWalletErr)
   202  
   203      const { asset, parentAsset } = this.current
   204      const selectedDef = this.current.selectedDef
   205      let parentForm
   206      let walletType = selectedDef.type
   207      if (parentAsset) {
   208        walletType = (asset.token as Token).definition.type
   209        parentForm = {
   210          assetID: parentAsset.id,
   211          config: this.subform.map(parentAsset.id),
   212          walletType: selectedDef.type
   213        }
   214      }
   215      // Register the selected asset.
   216      const res = await this.createWallet(asset.id, walletType, parentForm)
   217      if (!app().checkResponse(res)) {
   218        this.setError(res.msg)
   219        return
   220      }
   221      newWalletPass.value = ''
   222      if (parentAsset) await this.runParentSync()
   223      else this.success(this.current.asset.id)
   224    }
   225  
   226    /*
   227     * runParentSync shows a syncing sub-dialog that tracks the parent asset's
   228     * syncProgress and informs the user that the token wallet will be created
   229     * after sync is complete.
   230     */
   231    async runParentSync () {
   232      const { page, current: { parentAsset, asset } } = this
   233      if (!parentAsset) return
   234  
   235      page.parentSyncPct.textContent = '0'
   236      page.parentName.textContent = parentAsset.name
   237      page.parentLogo.src = Doc.logoPath(parentAsset.symbol)
   238      page.childName.textContent = asset.name
   239      page.childLogo.src = Doc.logoPath(asset.symbol)
   240      Doc.hide(page.mainForm)
   241      Doc.show(page.parentSyncing)
   242  
   243      try {
   244        await this.syncParent(parentAsset)
   245        this.success(this.current.asset.id)
   246      } catch (error) {
   247        this.setError(error.message || error)
   248      }
   249      Doc.show(page.mainForm)
   250      Doc.hide(page.parentSyncing)
   251    }
   252  
   253    /*
   254     * syncParent monitors the sync progress of a token's parent asset, generating
   255     * an Error if the token wallet creation does not complete successfully.
   256     */
   257    syncParent (parentAsset: SupportedAsset): Promise<void> {
   258      const { page, current: { asset } } = this
   259      return new Promise((resolve, reject) => {
   260        // First, check if it's already synced.
   261        const w = app().assets[parentAsset.id].wallet
   262        if (w && w.synced) return resolve()
   263        // Not synced, so create a syncer to update the parent sync pane.
   264        this.parentSyncer = (w: WalletState) => {
   265          if (w.assetID !== parentAsset.id) return
   266          page.parentSyncPct.textContent = (w.syncProgress * 100).toFixed(1)
   267        }
   268        // Handle the async result.
   269        this.createUpdater = (note: WalletCreationNote) => {
   270          if (note.assetID !== asset.id) return
   271          switch (note.topic) {
   272            case 'QueuedCreationFailed':
   273              reject(new Error(`${note.subject}: ${note.details}`))
   274              break
   275            case 'QueuedCreationSuccess':
   276              resolve()
   277              break
   278            default:
   279              return
   280          }
   281          this.parentSyncer = null
   282          this.createUpdater = null
   283        }
   284      })
   285    }
   286  
   287    /* setAsset sets the current asset of the NewWalletForm */
   288    async setAsset (assetID: number) {
   289      if (!this.parseAsset(assetID)) return // nothing to change
   290      const page = this.page
   291      const tabs = page.walletTypeTabs
   292      const { winfo, asset, parentAsset } = this.current
   293      page.assetName.textContent = winfo.name
   294      page.newWalletPass.value = ''
   295  
   296      Doc.empty(tabs)
   297      Doc.hide(tabs, page.newWalletErr, page.tokenMsgBox)
   298      this.page.assetLogo.src = Doc.logoPath(asset.symbol)
   299      if (parentAsset) {
   300        page.tokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
   301        page.tokenParentName.textContent = parentAsset.name
   302        Doc.show(page.tokenMsgBox)
   303      }
   304  
   305      const pinfo = parentAsset ? parentAsset.info : null
   306      const walletDefs = pinfo ? pinfo.availablewallets : (winfo as WalletInfo).availablewallets ? (winfo as WalletInfo).availablewallets : [(winfo as Token).definition]
   307  
   308      if (walletDefs.length > 1) {
   309        Doc.show(tabs)
   310        for (const wDef of walletDefs) {
   311          const tab = page.walletTabTmpl.cloneNode(true) as HTMLElement
   312          tab.dataset.tooltip = wDef.description
   313          tab.textContent = wDef.tab
   314          tabs.appendChild(tab)
   315          Doc.bind(tab, 'click', () => {
   316            for (const t of Doc.kids(tabs)) t.classList.remove('selected')
   317            tab.classList.add('selected')
   318            this.update(wDef)
   319          })
   320        }
   321        app().bindTooltips(tabs)
   322        const first = tabs.firstChild as HTMLElement
   323        first.classList.add('selected')
   324      }
   325  
   326      await this.update(this.current.selectedDef)
   327      if (asset.walletCreationPending) await this.runParentSync()
   328    }
   329  
   330    /*
   331    * parseAsset parses the current data for the asset ID.
   332    */
   333    parseAsset (assetID: number) {
   334      if (this.current && this.current.asset.id === assetID) return false
   335      const asset = app().assets[assetID]
   336      const token = asset.token
   337      if (!token) {
   338        if (!asset.info) throw Error('this non-token asset has no wallet info!')
   339        this.current = { asset, winfo: asset.info, selectedDef: asset.info.availablewallets[0] }
   340        return true
   341      }
   342      const parentAsset = app().user.assets[token.parentID]
   343      if (parentAsset.wallet) {
   344        // If the parent asset already has a wallet, there's no need to configure
   345        // the parent too. Just configure the token.
   346        this.current = { asset, winfo: token, selectedDef: token.definition }
   347        return true
   348      }
   349      if (!parentAsset.info) throw Error('this parent has no wallet info!')
   350      this.current = { asset, parentAsset, winfo: token, selectedDef: parentAsset.info.availablewallets[0] }
   351      return true
   352    }
   353  
   354    async update (walletDef: WalletDefinition) {
   355      const page = this.page
   356      this.current.selectedDef = walletDef
   357      Doc.hide(page.walletPassAndSubmitBttn, page.oneBttnBox, page.newWalletPassBox)
   358      const guideLink = walletDef.guidelink
   359      const configOpts = walletDef.configopts || []
   360      // If a config represents a wallet's birthday, we update the default
   361      // selection to the current date if this installation of the client
   362      // generated a seed.
   363      configOpts.map((opt) => {
   364        if (opt.isBirthdayConfig && app().seedGenTime > 0) {
   365          opt.default = toUnixDate(new Date())
   366        }
   367        return opt
   368      })
   369      // Either this is a walletDef for a token's uncreated parent asset, or this
   370      // is the definition for the token.
   371      let containsRequired = false
   372      for (const opt of configOpts) {
   373        if (opt.required) {
   374          containsRequired = true
   375          break
   376        }
   377      }
   378      const { asset, parentAsset, winfo } = this.current
   379      const displayCreateBtn = walletDef.seeded || Boolean(asset.token)
   380      if (displayCreateBtn && !containsRequired) {
   381        Doc.hide(page.walletSettingsHeader)
   382        Doc.show(page.oneBttnBox)
   383      } else if (displayCreateBtn) {
   384        Doc.show(page.walletPassAndSubmitBttn, page.walletSettingsHeader)
   385        page.newWalletPass.value = ''
   386        page.submitAdd.textContent = intl.prep(intl.ID_CREATE)
   387      } else {
   388        Doc.show(page.walletPassAndSubmitBttn, page.walletSettingsHeader)
   389        if (!walletDef.noauth) Doc.show(page.newWalletPassBox)
   390        page.submitAdd.textContent = intl.prep(intl.ID_ADD)
   391      }
   392  
   393      if (parentAsset) {
   394        const parentAndTokenOpts = JSON.parse(JSON.stringify(configOpts))
   395        // Add the regAsset field to the configurations so proper logos will be displayed
   396        // next to them, and map can filter them out. The opts are copied here so the originals
   397        // do not have the regAsset field added to them.
   398        for (const opt of parentAndTokenOpts) opt.regAsset = parentAsset.id
   399        const tokenOpts = (winfo as Token).definition.configopts || []
   400        if (tokenOpts.length > 0) {
   401          const tokenOptsCopy = JSON.parse(JSON.stringify(tokenOpts))
   402          for (const opt of tokenOptsCopy) opt.regAsset = asset.id
   403          parentAndTokenOpts.push(...tokenOptsCopy)
   404        }
   405        this.subform.update(asset.id, parentAndTokenOpts, false)
   406      } else this.subform.update(asset.id, configOpts, false)
   407      this.setGuideLink(guideLink)
   408  
   409      // A seeded or token wallet is internal to Bison Wallet and as such does
   410      // not have an external config file to select.
   411      if (walletDef.seeded || Boolean(this.current.asset.token)) Doc.hide(this.subform.fileSelector)
   412      else Doc.show(this.subform.fileSelector)
   413  
   414      await this.loadDefaults()
   415    }
   416  
   417    setGuideLink (guideLink: string) {
   418      Doc.hide(this.walletCfgGuide)
   419      if (guideLink !== '') {
   420        this.walletCfgGuide.href = guideLink
   421        Doc.show(this.walletCfgGuide)
   422      }
   423    }
   424  
   425    /* setError sets and shows the in-form error message. */
   426    async setError (errMsg: string) {
   427      this.page.newWalletErr.textContent = errMsg
   428      Doc.show(this.page.newWalletErr)
   429    }
   430  
   431    /*
   432     * loadDefaults attempts to load the ExchangeWallet configuration from the
   433     * default wallet config path on the server and will auto-fill the page on
   434     * the subform if settings are found.
   435     */
   436    async loadDefaults () {
   437      // No default config files for seeded assets right now.
   438      const { asset, parentAsset, selectedDef } = this.current
   439      if (!selectedDef.configpath) return
   440      let configID = asset.id
   441      if (parentAsset) {
   442        if (selectedDef.seeded) return
   443        configID = parentAsset.id
   444      }
   445      const loaded = app().loading(this.form)
   446      const res = await postJSON('/api/defaultwalletcfg', {
   447        assetID: configID,
   448        type: selectedDef.type
   449      })
   450      loaded()
   451      if (!app().checkResponse(res)) {
   452        this.setError(res.msg)
   453        return
   454      }
   455      this.subform.setLoadedConfig(res.config)
   456    }
   457  }
   458  
   459  let dynamicInputCounter = 0
   460  
   461  /*
   462   * WalletConfigForm is a dynamically generated sub-form for setting
   463   * asset-specific wallet configuration options.
   464  */
   465  export class WalletConfigForm {
   466    page: Record<string, PageElement>
   467    form: HTMLElement
   468    configElements: [ConfigOption, HTMLElement][]
   469    configOpts: ConfigOption[]
   470    sectionize: boolean
   471    allSettings: PageElement
   472    dynamicOpts: PageElement
   473    textInputTmpl: PageElement
   474    dateInputTmpl: PageElement
   475    checkboxTmpl: PageElement
   476    repeatableTmpl: PageElement
   477    fileSelector: PageElement
   478    fileInput: PageElement
   479    errMsg: PageElement
   480    showOther: PageElement
   481    showIcon: PageElement
   482    hideIcon: PageElement
   483    showHideMsg: PageElement
   484    otherSettings: PageElement
   485    loadedSettingsMsg: PageElement
   486    loadedSettings: PageElement
   487    defaultSettingsMsg: PageElement
   488    defaultSettings: PageElement
   489    assetHasActiveOrders: boolean
   490    assetID: number
   491  
   492    constructor (form: HTMLElement, sectionize: boolean) {
   493      this.page = Doc.idDescendants(form)
   494      this.form = form
   495      // A configElement is a div containing an input and its label.
   496      this.configElements = []
   497      // configOpts is the wallet options provided by core.
   498      this.configOpts = []
   499      this.sectionize = sectionize
   500  
   501      // Get template elements
   502      this.allSettings = Doc.tmplElement(form, 'allSettings')
   503      this.dynamicOpts = Doc.tmplElement(form, 'dynamicOpts')
   504      this.textInputTmpl = Doc.tmplElement(form, 'textInput')
   505      this.textInputTmpl.remove()
   506      this.dateInputTmpl = Doc.tmplElement(form, 'dateInput')
   507      this.dateInputTmpl.remove()
   508      this.checkboxTmpl = Doc.tmplElement(form, 'checkbox')
   509      this.checkboxTmpl.remove()
   510      this.repeatableTmpl = Doc.tmplElement(form, 'repeatableInput')
   511      this.repeatableTmpl.remove()
   512      this.fileSelector = Doc.tmplElement(form, 'fileSelector')
   513      this.fileInput = Doc.tmplElement(form, 'fileInput')
   514      this.errMsg = Doc.tmplElement(form, 'errMsg')
   515      this.showOther = Doc.tmplElement(form, 'showOther')
   516      this.showIcon = Doc.tmplElement(form, 'showIcon')
   517      this.hideIcon = Doc.tmplElement(form, 'hideIcon')
   518      this.showHideMsg = Doc.tmplElement(form, 'showHideMsg')
   519      this.otherSettings = Doc.tmplElement(form, 'otherSettings')
   520      this.loadedSettingsMsg = Doc.tmplElement(form, 'loadedSettingsMsg')
   521      this.loadedSettings = Doc.tmplElement(form, 'loadedSettings')
   522      this.defaultSettingsMsg = Doc.tmplElement(form, 'defaultSettingsMsg')
   523      this.defaultSettings = Doc.tmplElement(form, 'defaultSettings')
   524  
   525      if (!sectionize) Doc.hide(this.showOther)
   526  
   527      Doc.bind(this.fileSelector, 'click', () => this.fileInput.click())
   528  
   529      // config file upload
   530      Doc.bind(this.fileInput, 'change', async () => this.fileInputChanged())
   531  
   532      Doc.bind(this.showOther, 'click', () => {
   533        this.setOtherSettingsViz(this.hideIcon.classList.contains('d-hide'))
   534      })
   535    }
   536  
   537    /*
   538     * fileInputChanged will read the selected file and attempt to load the
   539     * configuration settings. All loaded settings will be made visible for
   540     * inspection by the user.
   541     */
   542    async fileInputChanged () {
   543      Doc.hide(this.errMsg)
   544      if (!this.fileInput.value) return
   545      const files = this.fileInput.files
   546      if (!files || files.length === 0) return
   547      const loaded = app().loading(this.form)
   548      const config = await files[0].text()
   549      if (!config) return
   550      const res = await postJSON('/api/parseconfig', {
   551        configtext: config
   552      })
   553      loaded()
   554      if (!app().checkResponse(res)) {
   555        this.errMsg.textContent = res.msg
   556        Doc.show(this.errMsg)
   557        return
   558      }
   559      if (Object.keys(res.map).length === 0) return
   560      this.dynamicOpts.append(...this.setConfig(res.map))
   561      this.reorder(this.dynamicOpts)
   562      const [loadedOpts, defaultOpts] = [this.loadedSettings.children.length, this.defaultSettings.children.length]
   563      if (loadedOpts === 0) Doc.hide(this.loadedSettings, this.loadedSettingsMsg)
   564      if (defaultOpts === 0) Doc.hide(this.defaultSettings, this.defaultSettingsMsg)
   565      if (loadedOpts + defaultOpts === 0) Doc.hide(this.showOther, this.otherSettings)
   566    }
   567  
   568    addOpt (box: HTMLElement, opt: ConfigOption, insertAfter?: PageElement, skipRepeatN?: boolean): PageElement {
   569      let el: HTMLElement
   570      if (opt.isboolean) el = this.checkboxTmpl.cloneNode(true) as HTMLElement
   571      else if (opt.isdate) el = this.dateInputTmpl.cloneNode(true) as HTMLElement
   572      else if (opt.repeatable) {
   573        el = this.repeatableTmpl.cloneNode(true) as HTMLElement
   574        el.classList.add('repeatable')
   575        Doc.bind(Doc.tmplElement(el, 'add'), 'click', () => {
   576          this.addOpt(box, opt, el, true)
   577        })
   578        if (!skipRepeatN) for (let i = 0; i < (opt.repeatN ? opt.repeatN - 1 : 0); i++) this.addOpt(box, opt, insertAfter, true)
   579      } else el = this.textInputTmpl.cloneNode(true) as HTMLElement
   580      const hiddenFields = app().extensionWallet(this.assetID)?.hiddenFields || []
   581      if (hiddenFields.indexOf(opt.key) !== -1) Doc.hide(el)
   582      this.configElements.push([opt, el])
   583      const input = el.querySelector('input') as ConfigOptionInput
   584      input.dataset.configKey = opt.key
   585      // We need to generate a unique ID only for the <input id> => <label for>
   586      // matching.
   587      dynamicInputCounter++
   588      const elID = 'wcfg-' + String(dynamicInputCounter)
   589      input.id = elID
   590      const label = Doc.safeSelector(el, 'label')
   591      label.htmlFor = elID // 'for' attribute, but 'for' is a keyword
   592      label.prepend(opt.displayname)
   593      if (opt.regAsset !== undefined) {
   594        const logo = new window.Image(15, 15)
   595        logo.src = Doc.logoPathFromID(opt.regAsset || -1)
   596        label.prepend(logo)
   597      }
   598      if (insertAfter) insertAfter.after(el)
   599      else box.appendChild(el)
   600      if (opt.noecho) {
   601        input.type = 'password'
   602        input.autocomplete = 'off'
   603      }
   604      if (opt.description) label.dataset.tooltip = opt.description
   605      if (opt.isboolean) input.checked = opt.default
   606      else if (opt.isdate) {
   607        const getMinMaxVal = (minMax: string | number) => {
   608          if (!minMax) return ''
   609          if (minMax === 'now') return dateToString(new Date())
   610          return dateToString(new Date((minMax as number) * 1000))
   611        }
   612        input.max = getMinMaxVal(opt.max)
   613        input.min = getMinMaxVal(opt.min)
   614        const date = opt.default ? new Date(opt.default * 1000) : new Date()
   615        // UI shows Dates in valueAsDate as UTC, but user interprets local. Set a
   616        // local date string so the UI displays what the user expects. alt:
   617        // input.valueAsDate = dateApplyOffset(date)
   618        input.value = dateToString(date)
   619      } else input.value = opt.default !== null ? opt.default : ''
   620      input.disabled = Boolean(opt.disablewhenactive && this.assetHasActiveOrders)
   621      return el
   622    }
   623  
   624    /*
   625     * update creates the dynamic form.
   626     */
   627    update (assetID: number, configOpts: ConfigOption[] | null, activeOrders: boolean) {
   628      this.assetHasActiveOrders = activeOrders
   629      this.configElements = []
   630      this.configOpts = configOpts || []
   631      this.assetID = assetID
   632      Doc.empty(this.dynamicOpts, this.defaultSettings, this.loadedSettings)
   633  
   634      // If there are no options, just hide the entire form.
   635      if (this.configOpts.length === 0) return Doc.hide(this.form)
   636      Doc.show(this.form)
   637  
   638      this.setOtherSettingsViz(false)
   639      Doc.hide(
   640        this.loadedSettingsMsg, this.loadedSettings, this.defaultSettingsMsg,
   641        this.defaultSettings, this.errMsg
   642      )
   643      const defaultedOpts = []
   644      for (const opt of this.configOpts) {
   645        if (this.sectionize && opt.default !== null) defaultedOpts.push(opt)
   646        else this.addOpt(this.dynamicOpts, opt)
   647      }
   648      if (defaultedOpts.length) {
   649        for (const opt of defaultedOpts) this.addOpt(this.defaultSettings, opt)
   650        Doc.show(this.showOther, this.defaultSettingsMsg, this.defaultSettings)
   651      } else {
   652        Doc.hide(this.showOther)
   653      }
   654      app().bindTooltips(this.allSettings)
   655      if (this.dynamicOpts.children.length) Doc.show(this.dynamicOpts)
   656      else Doc.hide(this.dynamicOpts)
   657    }
   658  
   659    /*
   660     * setOtherSettingsViz sets the visibility of the additional settings section.
   661     */
   662    setOtherSettingsViz (visible: boolean) {
   663      if (visible) {
   664        Doc.hide(this.showIcon)
   665        Doc.show(this.hideIcon, this.otherSettings)
   666        this.showHideMsg.textContent = intl.prep(intl.ID_HIDE_ADDITIONAL_SETTINGS)
   667        return
   668      }
   669      Doc.hide(this.hideIcon, this.otherSettings)
   670      Doc.show(this.showIcon)
   671      this.showHideMsg.textContent = intl.prep(intl.ID_SHOW_ADDITIONAL_SETTINGS)
   672    }
   673  
   674    /*
   675     * setConfig looks for inputs with configOpt keys matching the cfg object, and
   676     * sets the inputs value to the corresponding cfg value. A list of matching
   677     * configElements is returned.
   678     */
   679    setConfig (cfg: Record<string, string>): HTMLElement[] {
   680      const finds: HTMLElement[] = []
   681      const handledRepeatables: Record<string, boolean> = {}
   682      const removes: [ConfigOption, PageElement][] = []
   683      for (const r of [...this.configElements]) {
   684        const [opt, el] = r
   685        const v = cfg[opt.key]
   686        if (v === undefined) continue
   687        if (opt.repeatable) {
   688          if (handledRepeatables[opt.key]) {
   689            el.remove()
   690            removes.push(r)
   691            continue
   692          }
   693          handledRepeatables[opt.key] = true
   694          const vals = v.split(opt.repeatable)
   695          const firstVal = vals[0]
   696          finds.push(el)
   697          Doc.safeSelector(el, 'input').value = firstVal
   698          // Add repeatN - 1 empty elements to the reconfig form. Add them before
   699          // the populated inputs just because of the way we're using the
   700          // insertAfter argument to addOpt.
   701          for (let i = 1; i < (opt.repeatN || 1); i++) finds.push(this.addOpt(el.parentElement as PageElement, opt, el, true))
   702          for (let i = 1; i < vals.length; i++) {
   703            const newEl = this.addOpt(el.parentElement as PageElement, opt, el, true)
   704            Doc.safeSelector(newEl, 'input').value = vals[i]
   705            finds.push(newEl)
   706          }
   707          continue
   708        }
   709        finds.push(el)
   710        const input = Doc.safeSelector(el, 'input') as HTMLInputElement
   711        if (opt.isboolean) input.checked = isTruthyString(v)
   712        else if (opt.isdate) {
   713          input.value = dateToString(new Date(parseInt(v) * 1000))
   714          // alt: input.valueAsDate = dateApplyOffset(...)
   715        } else input.value = v
   716      }
   717      for (const r of removes) {
   718        const i = this.configElements.indexOf(r)
   719        if (i >= 0) this.configElements.splice(i, 1)
   720      }
   721  
   722      return finds
   723    }
   724  
   725    /*
   726     * setLoadedConfig sets the input values for the entries in cfg, and moves
   727     * them to the loadedSettings box.
   728     */
   729    setLoadedConfig (cfg: Record<string, string>) {
   730      const finds = this.setConfig(cfg)
   731      if (!this.sectionize || finds.length === 0) return
   732      this.loadedSettings.append(...finds)
   733      this.reorder(this.loadedSettings)
   734      Doc.show(this.loadedSettings, this.loadedSettingsMsg)
   735      if (this.defaultSettings.children.length === 0) Doc.hide(this.defaultSettings, this.defaultSettingsMsg)
   736    }
   737  
   738    /*
   739     * map reads all inputs and constructs an object from the configOpt keys and
   740     * values.
   741     */
   742    map (assetID: number): Record<string, string> {
   743      const config: Record<string, string> = {}
   744      for (const [opt, el] of this.configElements) {
   745        const input = Doc.safeSelector(el, 'input') as HTMLInputElement
   746        if (opt.regAsset !== undefined && opt.regAsset !== assetID) continue
   747        if (opt.isboolean && opt.key) {
   748          config[opt.key] = input.checked ? '1' : '0'
   749        } else if (opt.isdate && opt.key) {
   750          // Force local time interpretation by appending a time to the date
   751          // string, otherwise the Date constructor considers it UTC.
   752          const minDate = input.min ? toUnixDate(new Date(input.min + 'T00:00')) : Number.MIN_SAFE_INTEGER
   753          const maxDate = input.max ? toUnixDate(new Date(input.max + 'T00:00')) : Number.MAX_SAFE_INTEGER
   754          let date = input.value ? toUnixDate(new Date(input.value + 'T00:00')) : 0
   755          if (date < minDate) date = minDate
   756          else if (date > maxDate) date = maxDate
   757          config[opt.key] = String(date)
   758        } else if (input.value) {
   759          if (opt.repeatable && config[opt.key]) config[opt.key] += opt.repeatable + input.value
   760          else config[opt.key] = input.value
   761        }
   762      }
   763      return config
   764    }
   765  
   766    /*
   767     * reorder sorts the configElements in the box by the order of the
   768     * server-provided configOpts array.
   769     */
   770    reorder (box: HTMLElement) {
   771      const inputs: Record<string, HTMLElement[]> = {}
   772      box.querySelectorAll('input').forEach((input: ConfigOptionInput) => {
   773        const k = input.dataset.configKey
   774        if (!k) return // TS2538
   775        const els = []
   776        for (const [opt, el] of this.configElements) if (opt.key === k) els.push(el)
   777        inputs[k] = els
   778      })
   779      for (const opt of this.configOpts) {
   780        const els = inputs[opt.key] || []
   781        for (const el of els) box.append(el)
   782      }
   783    }
   784  }
   785  
   786  /*
   787   * ConfirmRegistrationForm should be used with the "confirmRegistrationForm"
   788   * template.
   789   */
   790  export class ConfirmRegistrationForm {
   791    form: HTMLElement
   792    success: () => void
   793    page: Record<string, PageElement>
   794    xc: Exchange
   795    certFile: string
   796    bondAssetID: number
   797    tier: number
   798    fees: number
   799  
   800    constructor (form: HTMLElement, success: () => void, goBack: () => void) {
   801      this.form = form
   802      this.success = success
   803      this.page = Doc.parseTemplate(form)
   804      this.certFile = ''
   805  
   806      Doc.bind(this.page.goBack, 'click', () => goBack())
   807      bind(form, this.page.submit, () => this.submitForm())
   808    }
   809  
   810    setExchange (xc: Exchange, certFile: string) {
   811      this.xc = xc
   812      this.certFile = certFile
   813      this.page.host.textContent = xc.host
   814    }
   815  
   816    setAsset (assetID: number, tier: number, fees: number) {
   817      const asset = app().assets[assetID]
   818      const { conversionFactor, unit } = asset.unitInfo.conventional
   819      this.bondAssetID = asset.id
   820      this.tier = tier
   821      this.fees = fees
   822      const page = this.page
   823      const bondAsset = this.xc.bondAssets[asset.symbol]
   824      const bondLock = bondAsset.amount * tier * bondReserveMultiplier
   825      const bondLockConventional = bondLock / conversionFactor
   826      page.tradingTier.textContent = String(tier)
   827      page.logo.src = Doc.logoPath(asset.symbol)
   828      page.bondLock.textContent = Doc.formatFourSigFigs(bondLockConventional)
   829      page.bondUnit.textContent = unit
   830      const r = app().fiatRatesMap[assetID]
   831      Doc.show(page.bondLockUSDBox)
   832      if (r) page.bondLockUSD.textContent = Doc.formatFourSigFigs(bondLockConventional * r)
   833      else Doc.hide(page.bondLockUSDBox)
   834      if (fees) page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor)
   835      page.reservesUnit.textContent = unit
   836    }
   837  
   838    setFees (assetID: number, fees: number) {
   839      this.fees = fees
   840      const conversionFactor = app().assets[assetID].unitInfo.conventional.conversionFactor
   841      this.page.feeReserves.textContent = Doc.formatFourSigFigs(fees / conversionFactor)
   842    }
   843  
   844    /* Form expands into its space quickly from the lower-right as it fades in. */
   845    async animate () {
   846      const form = this.form
   847      Doc.animate(400, prog => {
   848        form.style.transform = `scale(${prog})`
   849        form.style.opacity = String(Math.pow(prog, 4))
   850        const offset = `${(1 - prog) * 500}px`
   851        form.style.top = offset
   852        form.style.left = offset
   853      })
   854    }
   855  
   856    /*
   857     * submitForm is called when the form is submitted.
   858     */
   859    async submitForm () {
   860      const { page, bondAssetID, xc, certFile, tier } = this
   861      const asset = app().assets[bondAssetID]
   862      if (!asset) {
   863        page.regErr.innerText = intl.prep(intl.ID_SELECT_WALLET_FOR_FEE_PAYMENT)
   864        Doc.show(page.regErr)
   865        return
   866      }
   867      Doc.hide(page.regErr)
   868      const bondAsset = xc.bondAssets[asset.wallet.symbol]
   869      const dexAddr = xc.host
   870      let form: any
   871      let url: string
   872      if (!app().exchanges[xc.host] || app().exchanges[xc.host].viewOnly) {
   873        form = {
   874          addr: dexAddr,
   875          cert: certFile,
   876          bond: bondAsset.amount * tier,
   877          asset: bondAsset.id
   878        }
   879        url = '/api/postbond'
   880      } else {
   881        form = {
   882          host: dexAddr,
   883          targetTier: tier,
   884          bondAssetID: bondAssetID
   885        }
   886        url = '/api/updatebondoptions'
   887      }
   888      const loaded = app().loading(this.form)
   889      const res = await postJSON(url, form)
   890      loaded()
   891      if (!app().checkResponse(res)) {
   892        page.regErr.textContent = res.msg
   893        Doc.show(page.regErr)
   894        return
   895      }
   896      this.success()
   897    }
   898  }
   899  
   900  interface RegAssetRow {
   901    ready: PageElement
   902  }
   903  
   904  interface MarketLimitsRow {
   905    mkt: Market
   906    tmpl: Record<string, PageElement>
   907    setTier: ((tier: number) => void)
   908  }
   909  
   910  /*
   911   * FeeAssetSelectionForm should be used with the "regAssetForm" template.
   912   */
   913  export class FeeAssetSelectionForm {
   914    form: HTMLElement
   915    success: (assetID: number, tier: number) => Promise<void>
   916    xc: Exchange
   917    selectedAssetID: number
   918    certFile: string
   919    page: Record<string, PageElement>
   920    assetRows: Record<string, RegAssetRow>
   921    marketRows: MarketLimitsRow[]
   922  
   923    constructor (form: HTMLElement, success: (assetID: number, tier: number) => Promise<void>) {
   924      this.form = form
   925      this.certFile = ''
   926      this.success = success
   927      const page = this.page = Doc.parseTemplate(form)
   928      Doc.cleanTemplates(page.currentBondTmpl, page.bondAssetTmpl, page.marketTmpl)
   929  
   930      Doc.bind(page.tradingTierInput, 'input', () => { this.setTier() })
   931      Doc.bind(page.tradingTierInput, 'keyup', (e: KeyboardEvent) => { if (e.key === 'Enter') this.acceptTier() })
   932      Doc.bind(page.submitTradingTier, 'click', () => { this.acceptTier() })
   933  
   934      Doc.bind(page.tierUp, 'click', () => { this.incrementTier(true) })
   935      Doc.bind(page.tierDown, 'click', () => { this.incrementTier(false) })
   936  
   937      Doc.bind(page.goBackToAssets, 'click', () => {
   938        Doc.hide(page.tradingTierForm)
   939        Doc.show(page.assetForm)
   940      })
   941  
   942      Doc.bind(page.whatsABond, 'click', () => {
   943        Doc.hide(page.assetForm)
   944        Doc.show(page.whatsABondPanel)
   945      })
   946  
   947      const hideWhatsABond = () => {
   948        Doc.show(page.assetForm)
   949        Doc.hide(page.whatsABondPanel)
   950      }
   951  
   952      Doc.bind(page.bondGotIt, 'click', () => { hideWhatsABond() })
   953  
   954      Doc.bind(page.whatsABondBack, 'click', () => { hideWhatsABond() })
   955  
   956      Doc.bind(page.usePrepaidBond, 'click', () => { this.showPrepaidBondForm() })
   957      Doc.bind(page.ppbGoBack, 'click', () => { this.hidePrepaidBondForm() })
   958      Doc.bind(page.submitPrepaidBond, 'click', () => { this.submitPrepaidBond() })
   959  
   960      app().registerNoteFeeder({
   961        createwallet: (note: WalletCreationNote) => {
   962          if (note.topic === 'QueuedCreationSuccess') this.walletCreated(note.assetID)
   963        }
   964      })
   965    }
   966  
   967    setTierError (errMsg: string) {
   968      this.page.tradingTierErr.textContent = errMsg
   969      Doc.show(this.page.tradingTierErr)
   970    }
   971  
   972    setAssetError (errMsg: string) {
   973      this.page.regAssetErr.textContent = errMsg
   974      Doc.show(this.page.regAssetErr)
   975    }
   976  
   977    clearErrors () {
   978      Doc.hide(this.page.regAssetErr, this.page.tradingTierErr)
   979    }
   980  
   981    setExchange (xc: Exchange, certFile: string) {
   982      this.xc = xc
   983      this.certFile = certFile
   984      this.assetRows = {}
   985      this.marketRows = []
   986      const page = this.page
   987      Doc.hide(page.assetForm, page.tradingTierForm, page.whatsABondPanel, page.prepaidBonds)
   988      Doc.empty(page.bondAssets, page.markets)
   989      this.clearErrors()
   990  
   991      const addBondRow = (assetID: number, bondAsset: BondAsset) => {
   992        const asset = app().assets[assetID]
   993        if (!asset) return
   994        const { unitInfo: { conventional: { unit, conversionFactor } }, name, symbol } = asset
   995        const tr = page.bondAssetTmpl.cloneNode(true) as HTMLElement
   996        page.bondAssets.appendChild(tr)
   997        const tmpl = Doc.parseTemplate(tr)
   998  
   999        tmpl.logo.src = Doc.logoPath(symbol)
  1000        tmpl.name.textContent = name
  1001  
  1002        Doc.bind(tr, 'click', () => { this.assetSelected(assetID) })
  1003        tmpl.feeSymbol.textContent = unit
  1004        const bondSizeConventional = bondAsset.amount / conversionFactor
  1005        tmpl.feeAmt.textContent = Doc.formatFourSigFigs(bondSizeConventional)
  1006        const fiatRate = app().fiatRatesMap[assetID]
  1007        Doc.setVis(fiatRate, tmpl.fiatBox)
  1008        if (fiatRate) tmpl.fiatBondAmount.textContent = Doc.formatFourSigFigs(bondSizeConventional * fiatRate)
  1009        this.assetRows[assetID] = { ready: tmpl.ready }
  1010      }
  1011  
  1012      const addMarketRow = (mkt: Market) => {
  1013        const { baseid: baseID, quoteid: quoteID } = mkt
  1014        const [b, q] = [app().assets[baseID], app().assets[quoteID]]
  1015        if (!b || !q) return
  1016        const tr = page.marketTmpl.cloneNode(true) as HTMLElement
  1017        page.markets.appendChild(tr)
  1018        const { symbol: baseSymbol, unitInfo: bui } = xc.assets[baseID]
  1019        const { symbol: quoteSymbol, unitInfo: qui } = xc.assets[quoteID]
  1020        for (const el of Doc.applySelector(tr, '[data-base-ticker]')) el.textContent = bui.conventional.unit
  1021        for (const el of Doc.applySelector(tr, '[data-quote-ticker]')) el.textContent = qui.conventional.unit
  1022  
  1023        const tmpl = Doc.parseTemplate(tr)
  1024        tmpl.baseLogo.src = Doc.logoPath(baseSymbol)
  1025        tmpl.quoteLogo.src = Doc.logoPath(quoteSymbol)
  1026  
  1027        const setTier = (tier: number) => {
  1028          const { parcelsize: parcelSize, lotsize: lotSize } = mkt
  1029          const conventionalLotSize = lotSize / bui.conventional.conversionFactor
  1030          const startingLimit = conventionalLotSize * parcelSize * perTierBaseParcelLimit * tier
  1031          const privilegedLimit = conventionalLotSize * parcelSize * perTierBaseParcelLimit * parcelLimitScoreMultiplier * tier
  1032          tmpl.tradeLimitLow.textContent = Doc.formatFourSigFigs(startingLimit)
  1033          tmpl.tradeLimitHigh.textContent = Doc.formatFourSigFigs(privilegedLimit)
  1034          const baseFiatRate = app().fiatRatesMap[baseID]
  1035          if (baseFiatRate) {
  1036            tmpl.fiatTradeLimitLow.textContent = Doc.formatFourSigFigs(startingLimit * baseFiatRate)
  1037            tmpl.fiatTradeLimitHigh.textContent = Doc.formatFourSigFigs(privilegedLimit * baseFiatRate)
  1038          }
  1039          Doc.setVis(baseFiatRate, page.fiatTradeLowBox, page.fiatTradeHighBox)
  1040        }
  1041  
  1042        setTier(strongTier(xc.auth) || 1)
  1043        this.marketRows.push({ mkt, tmpl, setTier })
  1044      }
  1045  
  1046      for (const { symbol, id: assetID } of Object.values(xc.assets || {})) {
  1047        if (!app().assets[assetID]) continue
  1048        const bondAsset = xc.bondAssets[symbol]
  1049        if (bondAsset) addBondRow(assetID, bondAsset)
  1050      }
  1051  
  1052      for (const mkt of Object.values(xc.markets || {})) addMarketRow(mkt)
  1053  
  1054      // page.host.textContent = xc.host
  1055      page.tradingTierInput.value = xc.auth.targetTier ? String(xc.auth.targetTier) : '1'
  1056  
  1057      if (this.validBondAssetSelected(xc)) this.assetSelected(xc.auth.bondAssetID)
  1058      else Doc.show(page.assetForm)
  1059    }
  1060  
  1061    validBondAssetSelected (xc: Exchange) {
  1062      if (xc.viewOnly) return false
  1063      const { targetTier, bondAssetID } = xc.auth
  1064      if (targetTier < 1) return false
  1065      const a = app().assets[bondAssetID]
  1066      return a && Boolean(xc.bondAssets[a.symbol])
  1067    }
  1068  
  1069    /*
  1070     * walletCreated should be called when an asynchronous wallet creation
  1071     * completes successfully.
  1072     */
  1073    walletCreated (assetID: number) {
  1074      const a = this.assetRows[assetID]
  1075      const asset = app().assets[assetID]
  1076      setReadyMessage(a.ready, asset)
  1077    }
  1078  
  1079    refresh () {
  1080      this.setExchange(this.xc, this.certFile)
  1081    }
  1082  
  1083    assetSelected (assetID: number) {
  1084      this.selectedAssetID = assetID
  1085      this.setTier()
  1086      const { page: { assetForm, tradingTierForm, tradingTierInput } } = this
  1087      Doc.hide(assetForm)
  1088      Doc.show(tradingTierForm)
  1089      tradingTierInput.focus()
  1090    }
  1091  
  1092    setTier () {
  1093      const { page, xc: { bondAssets }, selectedAssetID: assetID } = this
  1094      const { symbol, unitInfo: ui } = app().assets[assetID]
  1095      const { conventional: { conversionFactor, unit } } = ui
  1096  
  1097      const bondAsset = bondAssets[symbol]
  1098      const raw = page.tradingTierInput.value ?? ''
  1099      if (!raw) return
  1100      const tier = parseInt(raw)
  1101      if (isNaN(tier)) {
  1102        this.setTierError(intl.prep(intl.ID_INVALID_TIER_VALUE))
  1103        return
  1104      }
  1105      page.tradingTierInput.value = String(tier)
  1106      page.bondSizeDisplay.textContent = Doc.formatCoinValue(bondAsset.amount, ui)
  1107      for (const el of Doc.applySelector(page.tradingTierForm, '[data-tier]')) el.textContent = String(tier)
  1108      for (const el of Doc.applySelector(page.tradingTierForm, '[data-bond-asset-ticker]')) el.textContent = unit
  1109      const bondLock = bondAsset.amount * tier * bondReserveMultiplier
  1110      page.bondLockDisplay.textContent = Doc.formatCoinValue(bondLock, ui)
  1111      const fiatRate = app().fiatRatesMap[assetID]
  1112      if (fiatRate) page.fiatLockDisplay.textContent = Doc.formatFourSigFigs(bondLock / conversionFactor * fiatRate)
  1113      for (const m of Object.values(this.marketRows)) m.setTier(tier)
  1114      const currentBondAmts: Record<number, number> = {}
  1115      for (const [assetIDStr, { wallet }] of Object.entries(app().assets)) {
  1116        if (!wallet) continue
  1117        const { balance: { bondlocked, bondReserves } } = wallet
  1118        const bonded = bondlocked + bondReserves
  1119        if (bonded > 0) currentBondAmts[parseInt(assetIDStr)] = bonded
  1120      }
  1121      const haveLock = Object.keys(currentBondAmts).length > 0
  1122      Doc.setVis(haveLock, page.currentBondBox)
  1123      if (haveLock) {
  1124        Doc.empty(page.currentBonds)
  1125        for (const [assetIDStr, bondLocked] of Object.entries(currentBondAmts)) {
  1126          const assetID = parseInt(assetIDStr)
  1127          const { unitInfo: ui, symbol, name } = app().assets[assetID]
  1128          const { conventional: { conversionFactor, unit } } = ui
  1129          const tr = page.currentBondTmpl.cloneNode(true) as PageElement
  1130          page.currentBonds.appendChild(tr)
  1131          const tmpl = Doc.parseTemplate(tr)
  1132          tmpl.icon.src = Doc.logoPath(symbol)
  1133          tmpl.name.textContent = name
  1134          tmpl.amt.textContent = Doc.formatCoinValue(bondLocked, ui)
  1135          tmpl.ticker.textContent = unit
  1136          tmpl.name.textContent = name
  1137          const fiatRate = app().fiatRatesMap[assetID]
  1138          Doc.setVis(tmpl.fiatBox)
  1139          if (fiatRate) tmpl.fiatAmt.textContent = Doc.formatFourSigFigs(bondLocked / conversionFactor * fiatRate)
  1140        }
  1141      }
  1142      Doc.setVis(fiatRate, page.fiatLockBox)
  1143    }
  1144  
  1145    acceptTier () {
  1146      const { page, selectedAssetID: assetID } = this
  1147      this.clearErrors()
  1148      const raw = page.tradingTierInput.value ?? ''
  1149      if (!raw) return
  1150      const tier = parseInt(raw)
  1151      if (isNaN(tier)) {
  1152        this.setTierError(intl.prep(intl.ID_INVALID_TIER_VALUE))
  1153        return
  1154      }
  1155      this.success(assetID, tier)
  1156    }
  1157  
  1158    incrementTier (up: boolean) {
  1159      const { page: { tradingTierInput: input } } = this
  1160      input.value = String(Math.max(1, (parseInt(input.value ?? '') || 1) + (up ? 1 : -1)))
  1161      this.setTier()
  1162    }
  1163  
  1164    /*
  1165     * Animation to make the elements sort of expand into their space from the
  1166     * bottom as they fade in.
  1167     */
  1168    async animate () {
  1169      const { page, form } = this
  1170      const extraMargin = 75
  1171      const extraTop = 50
  1172      const regAssetElements = Array.from(page.bondAssets.children) as PageElement[]
  1173      form.style.opacity = '0'
  1174  
  1175      const aniLen = 350
  1176      await Doc.animate(aniLen, prog => {
  1177        for (const el of regAssetElements) {
  1178          el.style.marginTop = `${(1 - prog) * extraMargin}px`
  1179          el.style.transform = `scale(${prog})`
  1180        }
  1181        form.style.opacity = Math.pow(prog, 4).toFixed(1)
  1182        form.style.top = `${(1 - prog) * extraTop}px`
  1183      }, 'easeOut')
  1184    }
  1185  
  1186    showPrepaidBondForm () {
  1187      const { page } = this
  1188      Doc.hide(page.assetForm, page.prepaidBondErr)
  1189      page.prepaidBondCode.value = ''
  1190      Doc.show(page.prepaidBonds)
  1191    }
  1192  
  1193    hidePrepaidBondForm () {
  1194      const { page } = this
  1195      Doc.hide(page.prepaidBonds)
  1196      Doc.show(page.assetForm)
  1197    }
  1198  
  1199    async submitPrepaidBond () {
  1200      const { page, xc: { host } } = this
  1201      Doc.hide(page.prepaidBondErr)
  1202      const code = page.prepaidBondCode.value
  1203      if (!code) {
  1204        page.prepaidBondErr.textContent = intl.prep(intl.ID_INVALID_VALUE)
  1205        Doc.show(page.prepaidBondErr)
  1206        return
  1207      }
  1208      const res = await postJSON('/api/redeemprepaidbond', { host, code, cert: this.certFile })
  1209      if (!app().checkResponse(res)) {
  1210        page.prepaidBondErr.textContent = res.msg
  1211        Doc.show(page.prepaidBondErr)
  1212        return
  1213      }
  1214      this.success(PrepaidBondID, res.tier)
  1215    }
  1216  }
  1217  
  1218  /*
  1219   * setReadyMessage sets an asset's status message on the FeeAssetSelectionForm.
  1220   */
  1221  function setReadyMessage (el: PageElement, asset: SupportedAsset) {
  1222    if (asset.wallet) el.textContent = intl.prep(intl.ID_WALLET_READY)
  1223    else if (asset.walletCreationPending) el.textContent = intl.prep(intl.ID_WALLET_PENDING)
  1224    else el.textContent = intl.prep(intl.ID_SETUP_NEEDED)
  1225    el.classList.remove('readygreen', 'setuporange')
  1226    el.classList.add(asset.wallet ? 'readygreen' : 'setuporange')
  1227  }
  1228  
  1229  /*
  1230   * WalletWaitForm is a form used to track the wallet sync status and balance
  1231   * in preparation for posting a bond.
  1232   */
  1233  export class WalletWaitForm {
  1234    form: HTMLElement
  1235    success: () => void
  1236    goBack: () => void
  1237    page: Record<string, PageElement>
  1238    assetID: number
  1239    parentID?: number
  1240    xc: Exchange
  1241    bondAsset: BondAsset
  1242    progressCache: ProgressPoint[]
  1243    progressed: boolean
  1244    funded: boolean
  1245    // if progressed && funded, stop reporting balance or state; call success()
  1246    bondFeeBuffer: number // in parent asset
  1247    parentAssetSynced: boolean
  1248  
  1249    constructor (form: HTMLElement, success: () => void, goBack: () => void) {
  1250      this.form = form
  1251      this.success = success
  1252      this.page = Doc.parseTemplate(form)
  1253      this.assetID = -1
  1254      this.progressCache = []
  1255      this.progressed = false
  1256      this.funded = false
  1257  
  1258      Doc.bind(this.page.goBack, 'click', () => {
  1259        this.assetID = -1
  1260        goBack()
  1261      })
  1262  
  1263      app().registerNoteFeeder({
  1264        walletstate: (note: WalletStateNote) => this.reportWalletState(note.wallet),
  1265        walletsync: (note: WalletSyncNote) => {
  1266          if (note.assetID !== this.assetID) return
  1267          const w = app().walletMap[note.assetID]
  1268          this.reportProgress(w.synced, w.syncProgress)
  1269        },
  1270        balance: (note: BalanceNote) => this.reportBalance(note.assetID)
  1271      })
  1272    }
  1273  
  1274    /* setExchange sets the exchange for which the fee is being paid. */
  1275    setExchange (xc: Exchange) {
  1276      this.xc = xc
  1277    }
  1278  
  1279    /* setWallet must be called before showing the WalletWaitForm. */
  1280    setWallet (assetID: number, bondFeeBuffer: number, tier: number) {
  1281      this.assetID = assetID
  1282      this.progressCache = []
  1283      this.progressed = false
  1284      this.funded = false
  1285      this.bondFeeBuffer = bondFeeBuffer // in case we're a token, parent's balance must cover
  1286      this.parentAssetSynced = false
  1287      const page = this.page
  1288      const asset = app().assets[assetID]
  1289      const { symbol, unitInfo: ui, wallet: { balance: bal, address, synced, syncProgress }, token } = asset
  1290      this.parentID = token?.parentID
  1291      const bondAsset = this.bondAsset = this.xc.bondAssets[symbol]
  1292  
  1293      const symbolize = (el: PageElement, asset: SupportedAsset) => {
  1294        Doc.empty(el)
  1295        el.appendChild(Doc.symbolize(asset))
  1296      }
  1297  
  1298      for (const span of Doc.applySelector(this.form, '.unit')) symbolize(span, asset)
  1299      page.logo.src = Doc.logoPath(symbol)
  1300      page.depoAddr.textContent = address
  1301  
  1302      Doc.hide(page.syncUncheck, page.syncCheck, page.balUncheck, page.balCheck, page.syncRemainBox, page.bondCostBreakdown)
  1303      Doc.show(page.balanceBox)
  1304  
  1305      let bondLock = 2 * bondAsset.amount * tier
  1306      if (bondFeeBuffer > 0) {
  1307        Doc.show(page.bondCostBreakdown)
  1308        page.bondLockNoFees.textContent = Doc.formatCoinValue(bondLock, ui)
  1309        page.bondLockFees.textContent = Doc.formatCoinValue(bondFeeBuffer, ui)
  1310        bondLock += bondFeeBuffer
  1311        const need = Math.max(bondLock - bal.available + bal.reservesDeficit, 0)
  1312        page.totalForBond.textContent = Doc.formatCoinValue(need, ui)
  1313        Doc.hide(page.sendEnough) // generic msg when no fee info available when
  1314        Doc.hide(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox) // for tokens
  1315        Doc.hide(page.sendEnoughWithEst) // non-tokens
  1316  
  1317        if (token) {
  1318          Doc.show(page.txFeeBox, page.sendEnoughForToken, page.txFeeBalanceBox)
  1319          const parentAsset = app().assets[token.parentID]
  1320          page.txFee.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo)
  1321          page.parentFees.textContent = Doc.formatCoinValue(bondFeeBuffer, parentAsset.unitInfo)
  1322          page.tokenFees.textContent = Doc.formatCoinValue(need, ui)
  1323          symbolize(page.txFeeUnit, parentAsset)
  1324          symbolize(page.parentUnit, parentAsset)
  1325          symbolize(page.parentBalUnit, parentAsset)
  1326          page.parentBal.textContent = parentAsset.wallet ? Doc.formatCoinValue(parentAsset.wallet.balance.available, parentAsset.unitInfo) : '0'
  1327        } else {
  1328          Doc.show(page.sendEnoughWithEst)
  1329        }
  1330        page.fee.textContent = Doc.formatCoinValue(bondLock, ui)
  1331      } else { // show some generic message with no amounts, this shouldn't happen... show wallet error?
  1332        Doc.show(page.sendEnough)
  1333      }
  1334  
  1335      Doc.show(synced ? page.syncCheck : syncProgress >= 1 ? page.syncSpinner : page.syncUncheck)
  1336      Doc.show(bal.available >= 2 * bondAsset.amount + bondFeeBuffer ? page.balCheck : page.balUncheck)
  1337  
  1338      page.progress.textContent = (syncProgress * 100).toFixed(1)
  1339  
  1340      if (synced) {
  1341        this.progressed = true
  1342      }
  1343      this.reportBalance(assetID)
  1344    }
  1345  
  1346    /*
  1347     * reportWalletState sets the progress and balance, ultimately calling the
  1348     * success function if conditions are met.
  1349     */
  1350    reportWalletState (wallet: WalletState) {
  1351      if (this.progressed && this.funded) return
  1352      if (wallet.assetID === this.assetID) this.reportProgress(wallet.synced, wallet.syncProgress)
  1353      this.reportBalance(wallet.assetID)
  1354    }
  1355  
  1356    /*
  1357     * reportBalance sets the balance display and calls success if we go over the
  1358     * threshold.
  1359     */
  1360    reportBalance (assetID: number) {
  1361      if (this.funded || this.assetID === -1) return
  1362      if (assetID !== this.assetID && assetID !== this.parentID) return
  1363      const page = this.page
  1364      const asset = app().assets[this.assetID]
  1365  
  1366      const avail = asset.wallet.balance.available
  1367      page.balance.textContent = Doc.formatCoinValue(avail, asset.unitInfo)
  1368  
  1369      if (asset.token) {
  1370        const parentAsset = app().assets[asset.token.parentID]
  1371        const parentAvail = parentAsset.wallet.balance.available
  1372        page.parentBal.textContent = Doc.formatCoinValue(parentAvail, parentAsset.unitInfo)
  1373        if (parentAvail < this.bondFeeBuffer) return
  1374      }
  1375  
  1376      // NOTE: when/if we allow one-time bond post (no maintenance) from the UI we
  1377      // may allow to proceed as long as they have enough for tx fees. For now,
  1378      // the balance check box will remain unchecked and we will not proceed.
  1379      if (avail < 2 * this.bondAsset.amount + this.bondFeeBuffer) return
  1380  
  1381      Doc.show(page.balCheck)
  1382      Doc.hide(page.balUncheck, page.balanceBox, page.sendEnough)
  1383      this.funded = true
  1384      if (this.progressed) this.success()
  1385    }
  1386  
  1387    /*
  1388     * reportProgress sets the progress display and calls success if we are fully
  1389     * synced.
  1390     */
  1391    reportProgress (synced: boolean, prog: number) {
  1392      const page = this.page
  1393      if (synced) {
  1394        page.progress.textContent = '100'
  1395        Doc.hide(page.syncUncheck, page.syncRemainBox, page.syncSpinner)
  1396        Doc.show(page.syncCheck)
  1397        this.progressed = true
  1398        if (this.funded) this.success()
  1399        return
  1400      } else if (prog === 1) {
  1401        Doc.hide(page.syncUncheck)
  1402        Doc.show(page.syncSpinner)
  1403      } else {
  1404        Doc.hide(page.syncSpinner)
  1405        Doc.show(page.syncUncheck)
  1406      }
  1407      page.progress.textContent = (prog * 100).toFixed(1)
  1408  
  1409      if (prog >= 0.999) {
  1410        Doc.hide(page.syncRemaining)
  1411        Doc.show(page.syncFinishingUp)
  1412        Doc.show(page.syncRemainBox)
  1413        // The final stage of wallet sync process can take a while (it might hang
  1414        // at 99.9% for many minutes, indexing addresses for example), the simplest
  1415        // way to handle it is to keep displaying "finishing up" message until the
  1416        // sync is finished, since we can't reasonably show it progressing over time.
  1417        page.syncFinishingUp.textContent = intl.prep(intl.ID_WALLET_SYNC_FINISHING_UP)
  1418        return
  1419      }
  1420      // Before we get to 99.9% the remaining time estimate must be based on more
  1421      // than one progress report. We'll cache up to the last 20 and look at the
  1422      // difference between the first and last to make the estimate.
  1423      const cacheSize = 20
  1424      const cache = this.progressCache
  1425      cache.push({
  1426        stamp: new Date().getTime(),
  1427        progress: prog
  1428      })
  1429      if (cache.length < 2) {
  1430        // Can't meaningfully estimate remaining until we have at least 2 data points.
  1431        return
  1432      }
  1433      while (cache.length > cacheSize) cache.shift()
  1434      const [first, last] = [cache[0], cache[cache.length - 1]]
  1435      const progDelta = last.progress - first.progress
  1436      if (progDelta === 0) {
  1437        // Having no progress for a while likely means we are experiencing network
  1438        // issues, can't reasonably estimate time remaining in this case.
  1439        return
  1440      }
  1441      Doc.hide(page.syncFinishingUp)
  1442      Doc.show(page.syncRemaining)
  1443      Doc.show(page.syncRemainBox)
  1444      const timeDelta = last.stamp - first.stamp
  1445      const progRate = progDelta / timeDelta
  1446      const toGoProg = 1 - last.progress
  1447      const toGoTime = toGoProg / progRate
  1448      page.syncRemain.textContent = Doc.formatDuration(toGoTime)
  1449    }
  1450  }
  1451  
  1452  interface EarlyAcceleration {
  1453    timePast: number,
  1454    wasAcceleration: boolean
  1455  }
  1456  
  1457  interface PreAccelerate {
  1458    swapRate: number
  1459    suggestedRate: number
  1460    suggestedRange: XYRange
  1461    earlyAcceleration?: EarlyAcceleration
  1462  }
  1463  
  1464  /*
  1465   * AccelerateOrderForm is used to submit an acceleration request for an order.
  1466   */
  1467  export class AccelerateOrderForm {
  1468    form: HTMLElement
  1469    page: Record<string, PageElement>
  1470    order: Order
  1471    acceleratedRate: number
  1472    earlyAcceleration?: EarlyAcceleration
  1473    currencyUnit: string
  1474    success: () => void
  1475  
  1476    constructor (form: HTMLElement, success: () => void) {
  1477      this.form = form
  1478      this.success = success
  1479      const page = this.page = Doc.idDescendants(form)
  1480  
  1481      Doc.bind(page.accelerateSubmit, 'click', () => {
  1482        this.submit()
  1483      })
  1484      Doc.bind(page.submitEarlyConfirm, 'click', () => {
  1485        this.sendAccelerateRequest()
  1486      })
  1487    }
  1488  
  1489    /*
  1490     * displayEarlyAccelerationMsg displays a message asking for confirmation
  1491     * when the user tries to submit an acceleration transaction very soon after
  1492     * the swap transaction was broadcast, or very soon after a previous
  1493     * acceleration.
  1494     */
  1495    displayEarlyAccelerationMsg () {
  1496      const page = this.page
  1497      // this is checked in submit, but another check is needed for ts compiler
  1498      if (!this.earlyAcceleration) return
  1499      page.recentAccelerationTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1500      page.recentSwapTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1501      if (this.earlyAcceleration.wasAcceleration) {
  1502        Doc.show(page.recentAccelerationMsg)
  1503        Doc.hide(page.recentSwapMsg)
  1504        page.recentAccelerationTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1505      } else {
  1506        Doc.show(page.recentSwapMsg)
  1507        Doc.hide(page.recentAccelerationMsg)
  1508        page.recentSwapTime.textContent = `${Math.floor(this.earlyAcceleration.timePast / 60)}`
  1509      }
  1510      Doc.hide(page.configureAccelerationDiv, page.accelerateErr)
  1511      Doc.show(page.earlyAccelerationDiv)
  1512    }
  1513  
  1514    // sendAccelerateRequest makes an accelerate order request to the client
  1515    // backend.
  1516    async sendAccelerateRequest () {
  1517      const order = this.order
  1518      const page = this.page
  1519      const req = {
  1520        orderID: order.id,
  1521        newRate: this.acceleratedRate
  1522      }
  1523      const loaded = app().loading(page.accelerateMainDiv)
  1524      const res = await postJSON('/api/accelerateorder', req)
  1525      loaded()
  1526      if (app().checkResponse(res)) {
  1527        page.accelerateTxID.textContent = res.txID
  1528        Doc.hide(page.accelerateMainDiv, page.preAccelerateErr, page.accelerateErr)
  1529        Doc.show(page.accelerateMsgDiv, page.accelerateSuccess)
  1530        this.success()
  1531      } else {
  1532        page.accelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_ERR_MSG, { msg: res.msg })
  1533        Doc.hide(page.earlyAccelerationDiv)
  1534        Doc.show(page.accelerateErr, page.configureAccelerationDiv)
  1535      }
  1536    }
  1537  
  1538    // submit is called when the submit button is clicked.
  1539    async submit () {
  1540      if (this.earlyAcceleration) {
  1541        this.displayEarlyAccelerationMsg()
  1542      } else {
  1543        this.sendAccelerateRequest()
  1544      }
  1545    }
  1546  
  1547    // refresh should be called before the form is displayed. It makes a
  1548    // preaccelerate request to the client backend and sets up the form
  1549    // based on the results.
  1550    async refresh (order: Order) {
  1551      const page = this.page
  1552      this.order = order
  1553      const res = await postJSON('/api/preaccelerate', order.id)
  1554      if (!app().checkResponse(res)) {
  1555        page.preAccelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_ERR_MSG, { msg: res.msg })
  1556        Doc.hide(page.accelerateMainDiv, page.accelerateSuccess)
  1557        Doc.show(page.accelerateMsgDiv, page.preAccelerateErr)
  1558        return
  1559      }
  1560      Doc.hide(page.accelerateMsgDiv, page.preAccelerateErr, page.accelerateErr, page.feeEstimateDiv, page.earlyAccelerationDiv)
  1561      Doc.show(page.accelerateMainDiv, page.accelerateSuccess, page.configureAccelerationDiv)
  1562      const preAccelerate: PreAccelerate = res.preAccelerate
  1563      this.earlyAcceleration = preAccelerate.earlyAcceleration
  1564      this.currencyUnit = preAccelerate.suggestedRange.yUnit
  1565      page.accelerateAvgFeeRate.textContent = `${preAccelerate.swapRate} ${preAccelerate.suggestedRange.yUnit}`
  1566      page.accelerateCurrentFeeRate.textContent = `${preAccelerate.suggestedRate} ${preAccelerate.suggestedRange.yUnit}`
  1567      this.acceleratedRate = preAccelerate.suggestedRange.start.y
  1568      const selected = () => { /* do nothing */ }
  1569      const roundY = true
  1570      const updateRate = (_: number, newY: number) => { this.acceleratedRate = newY }
  1571      const rangeHandler = new XYRangeHandler(preAccelerate.suggestedRange, preAccelerate.suggestedRange.start.x, {
  1572        updated: updateRate, changed: () => this.updateAccelerationEstimate(), selected, roundY
  1573      })
  1574      Doc.empty(page.sliderContainer)
  1575      page.sliderContainer.appendChild(rangeHandler.control)
  1576      this.updateAccelerationEstimate()
  1577    }
  1578  
  1579    // updateAccelerationEstimate makes an accelerate estimate request to the
  1580    // client backend using the currently selected rate on the slider, and
  1581    // displays the results.
  1582    async updateAccelerationEstimate () {
  1583      const page = this.page
  1584      const order = this.order
  1585      const req = {
  1586        orderID: order.id,
  1587        newRate: this.acceleratedRate
  1588      }
  1589      const loaded = app().loading(page.sliderContainer)
  1590      const res = await postJSON('/api/accelerationestimate', req)
  1591      loaded()
  1592      if (!app().checkResponse(res)) {
  1593        page.accelerateErr.textContent = intl.prep(intl.ID_ORDER_ACCELERATION_FEE_ERR_MSG, { msg: res.msg })
  1594        Doc.show(page.accelerateErr)
  1595        return
  1596      }
  1597      page.feeRateEstimate.textContent = `${this.acceleratedRate} ${this.currencyUnit}`
  1598      let assetID
  1599      let assetSymbol
  1600      if (order.sell) {
  1601        assetID = order.baseID
  1602        assetSymbol = order.baseSymbol
  1603      } else {
  1604        assetID = order.quoteID
  1605        assetSymbol = order.quoteSymbol
  1606      }
  1607      const unitInfo = app().unitInfo(assetID)
  1608      page.feeEstimate.textContent = `${res.fee / unitInfo.conventional.conversionFactor} ${assetSymbol}`
  1609      Doc.show(page.feeEstimateDiv)
  1610    }
  1611  }
  1612  
  1613  /* DEXAddressForm accepts a DEX address and performs account discovery. */
  1614  export class DEXAddressForm {
  1615    form: HTMLElement
  1616    success: (xc: Exchange, cert: string) => void
  1617    page: Record<string, PageElement>
  1618    knownExchanges: HTMLElement[]
  1619    dexToUpdate?: string
  1620    certPicker: CertificatePicker
  1621  
  1622    constructor (form: HTMLElement, success: (xc: Exchange, cert: string) => void, dexToUpdate?: string) {
  1623      this.form = form
  1624      this.success = success
  1625  
  1626      const page = this.page = Doc.parseTemplate(form)
  1627  
  1628      this.certPicker = new CertificatePicker(form)
  1629  
  1630      Doc.bind(page.skipRegistration, 'change', () => this.showOrHideSubmitBttn())
  1631      Doc.bind(page.showCustom, 'click', () => {
  1632        Doc.hide(page.showCustom)
  1633        Doc.show(page.customBox, page.auth)
  1634      })
  1635  
  1636      this.knownExchanges = Array.from(page.knownXCs.querySelectorAll('.known-exchange'))
  1637      for (const div of this.knownExchanges) {
  1638        Doc.bind(div, 'click', () => {
  1639          const host = div.dataset.host
  1640          for (const d of this.knownExchanges) d.classList.remove('selected')
  1641          return this.checkDEX(host)
  1642        })
  1643      }
  1644  
  1645      bind(form, page.submit, () => this.checkDEX())
  1646  
  1647      if (dexToUpdate) {
  1648        Doc.hide(page.addDexHdr, page.skipRegistrationBox)
  1649        Doc.show(page.updateDexHdr)
  1650        this.dexToUpdate = dexToUpdate
  1651      }
  1652  
  1653      this.refresh()
  1654    }
  1655  
  1656    refresh () {
  1657      const page = this.page
  1658      page.addr.value = ''
  1659      this.certPicker.clearCertFile()
  1660      Doc.hide(page.err)
  1661      if (this.knownExchanges.length === 0 || this.dexToUpdate) {
  1662        Doc.show(page.customBox, page.auth)
  1663        Doc.hide(page.showCustom, page.knownXCs, page.pickServerMsg, page.addCustomMsg)
  1664      } else {
  1665        Doc.hide(page.customBox)
  1666        Doc.show(page.showCustom)
  1667      }
  1668      for (const div of this.knownExchanges) div.classList.remove('selected')
  1669      this.showOrHideSubmitBttn()
  1670    }
  1671  
  1672    /**
  1673     * Show or hide appPWBox depending on if password is required. Show the
  1674     * submit button if connecting a custom server or password is required).
  1675     */
  1676    showOrHideSubmitBttn () {
  1677      const page = this.page
  1678      Doc.setVis(Doc.isDisplayed(page.customBox), page.auth)
  1679    }
  1680  
  1681    skipRegistration () : boolean {
  1682      return this.page.skipRegistration.checked ?? false
  1683    }
  1684  
  1685    /* Just a small size tweak and fade-in. */
  1686    async animate () {
  1687      const form = this.form
  1688      Doc.animate(550, prog => {
  1689        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1690        form.style.opacity = String(Math.pow(prog, 4))
  1691      }, 'easeOut')
  1692    }
  1693  
  1694    async checkDEX (addr?: string) {
  1695      const page = this.page
  1696      Doc.hide(page.err)
  1697      addr = addr || page.addr.value
  1698      if (addr === '') {
  1699        page.err.textContent = intl.prep(intl.ID_EMPTY_DEX_ADDRESS_MSG)
  1700        Doc.show(page.err)
  1701        return
  1702      }
  1703      const cert = await this.certPicker.file()
  1704      const skipRegistration = this.skipRegistration()
  1705      let endpoint : string, req: any
  1706      if (this.dexToUpdate) {
  1707        endpoint = '/api/updatedexhost'
  1708        req = {
  1709          newHost: addr,
  1710          cert: cert,
  1711          oldHost: this.dexToUpdate
  1712        }
  1713      } else {
  1714        endpoint = skipRegistration ? '/api/adddex' : '/api/discoveracct'
  1715        req = {
  1716          addr: addr,
  1717          cert: cert
  1718        }
  1719      }
  1720  
  1721      const loaded = app().loading(this.form)
  1722      const res = await postJSON(endpoint, req)
  1723      loaded()
  1724      if (!app().checkResponse(res)) {
  1725        if (String(res.msg).includes('certificate required')) {
  1726          Doc.show(page.needCert)
  1727        } else {
  1728          page.err.textContent = res.msg
  1729          Doc.show(page.err)
  1730        }
  1731        return
  1732      }
  1733      await app().fetchUser()
  1734      if (!this.dexToUpdate && (skipRegistration || res.paid || Object.keys(res.xc.auth.pendingBonds).length > 0)) {
  1735        await app().loadPage('markets')
  1736        return
  1737      }
  1738      this.success(res.xc, cert)
  1739    }
  1740  }
  1741  
  1742  /* DiscoverAccountForm performs account discovery for a pre-selected DEX. */
  1743  export class DiscoverAccountForm {
  1744    form: HTMLElement
  1745    addr: string
  1746    success: (xc: Exchange) => void
  1747    page: Record<string, PageElement>
  1748  
  1749    constructor (form: HTMLElement, addr: string, success: (xc: Exchange) => void) {
  1750      this.form = form
  1751      this.addr = addr
  1752      this.success = success
  1753  
  1754      const page = this.page = Doc.parseTemplate(form)
  1755      page.dexHost.textContent = addr
  1756      bind(form, page.submit, () => this.checkDEX())
  1757    }
  1758  
  1759    /* Just a small size tweak and fade-in. */
  1760    async animate () {
  1761      const form = this.form
  1762      Doc.animate(550, prog => {
  1763        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1764        form.style.opacity = String(Math.pow(prog, 4))
  1765      }, 'easeOut')
  1766    }
  1767  
  1768    async checkDEX () {
  1769      const page = this.page
  1770      Doc.hide(page.err)
  1771      const req = {
  1772        addr: this.addr
  1773      }
  1774      const loaded = app().loading(this.form)
  1775      const res = await postJSON('/api/discoveracct', req)
  1776      loaded()
  1777      if (!app().checkResponse(res)) {
  1778        page.err.textContent = res.msg
  1779        Doc.show(page.err)
  1780        return
  1781      }
  1782      if (res.paid) {
  1783        await app().fetchUser()
  1784        await app().loadPage('markets')
  1785        return
  1786      }
  1787      this.success(res.xc)
  1788    }
  1789  }
  1790  
  1791  /* LoginForm is used to sign into the app. */
  1792  export class LoginForm {
  1793    form: HTMLElement
  1794    success: () => void
  1795    page: Record<string, PageElement>
  1796  
  1797    constructor (form: HTMLElement, success: () => void) {
  1798      this.success = success
  1799      this.form = form
  1800      const page = this.page = Doc.parseTemplate(form)
  1801      bind(form, page.submit, () => { this.submit() })
  1802      app().registerNoteFeeder({
  1803        login: (note: CoreNote) => { this.handleLoginNote(note) }
  1804      })
  1805    }
  1806  
  1807    handleLoginNote (n: CoreNote) {
  1808      if (n.details === '') return
  1809      const loginMsg = Doc.idel(this.form, 'loaderMsg')
  1810      Doc.show(loginMsg)
  1811      if (loginMsg) loginMsg.textContent = n.details
  1812    }
  1813  
  1814    focus () {
  1815      this.page.pw.focus()
  1816    }
  1817  
  1818    refresh () {
  1819      Doc.hide(this.page.errMsg)
  1820      this.page.pw.value = ''
  1821    }
  1822  
  1823    async submit () {
  1824      const page = this.page
  1825      Doc.hide(page.errMsg)
  1826      const pw = page.pw.value || ''
  1827      if (pw === '') {
  1828        Doc.showFormError(page.errMsg, intl.prep(intl.ID_NO_PASS_ERROR_MSG))
  1829        return
  1830      }
  1831      const loaded = app().loading(this.form)
  1832      const res = await postJSON('/api/login', { pass: pw })
  1833      loaded()
  1834      page.pw.value = ''
  1835      if (!app().checkResponse(res)) {
  1836        Doc.showFormError(page.errMsg, res.msg)
  1837        return
  1838      }
  1839      await app().fetchUser()
  1840      res.notes = res.notes || []
  1841      res.notes.reverse()
  1842      res.pokes = res.pokes || []
  1843      app().loggedIn(res.notes, res.pokes)
  1844      this.success()
  1845    }
  1846  
  1847    /* Just a small size tweak and fade-in. */
  1848    async animate () {
  1849      const form = this.form
  1850      Doc.animate(550, prog => {
  1851        form.style.transform = `scale(${0.9 + 0.1 * prog})`
  1852        form.style.opacity = String(Math.pow(prog, 4))
  1853      }, 'easeOut')
  1854    }
  1855  }
  1856  
  1857  const traitNewAddresser = 1 << 1
  1858  
  1859  /*
  1860   * DepositAddress displays a deposit address, a QR code, and a button to
  1861   * generate a new address (if supported).
  1862   */
  1863  export class DepositAddress {
  1864    form: PageElement
  1865    page: Record<string, PageElement>
  1866    assetID: number
  1867  
  1868    constructor (form: PageElement) {
  1869      this.form = form
  1870      const page = this.page = Doc.idDescendants(form)
  1871      Doc.cleanTemplates(page.unifiedReceiverTmpl)
  1872      Doc.bind(page.newDepAddrBttn, 'click', async () => { this.newDepositAddress() })
  1873      Doc.bind(page.copyAddressBtn, 'click', () => { this.copyAddress() })
  1874    }
  1875  
  1876    /* Display a deposit address. */
  1877    async setAsset (assetID: number) {
  1878      this.assetID = assetID
  1879      const page = this.page
  1880      Doc.hide(page.depositErr, page.depositTokenMsgBox)
  1881      const asset = app().assets[assetID]
  1882      page.depositLogo.src = Doc.logoPath(asset.symbol)
  1883      const wallet = app().walletMap[assetID]
  1884      page.depositName.textContent = asset.unitInfo.conventional.unit
  1885      if (asset.token) {
  1886        const parentAsset = app().assets[asset.token.parentID]
  1887        page.depositTokenParentLogo.src = Doc.logoPath(parentAsset.symbol)
  1888        page.depositTokenParentName.textContent = parentAsset.name
  1889        Doc.show(page.depositTokenMsgBox)
  1890      }
  1891      Doc.setVis((wallet.traits & traitNewAddresser) !== 0, page.newDepAddrBttnBox)
  1892      this.setAddress(wallet.address)
  1893    }
  1894  
  1895    setAddress (addr: string) {
  1896      const page = this.page
  1897      Doc.hide(page.unifiedReceivers)
  1898      if (addr.startsWith('unified:')) {
  1899        const receivers = JSON.parse(addr.substring('unified:'.length)) as Record<string, string>
  1900        Doc.empty(page.unifiedReceivers)
  1901        Doc.show(page.unifiedReceivers)
  1902        const defaultReceiverType = 'unified'
  1903        for (const [recvType, recv] of Object.entries(receivers)) {
  1904          const div = page.unifiedReceiverTmpl.cloneNode(true) as PageElement
  1905          page.unifiedReceivers.appendChild(div)
  1906          div.textContent = recvType
  1907          div.dataset.type = recvType
  1908          if (recvType === defaultReceiverType) div.classList.add('selected')
  1909          // tmpl.addr.textContent = recv
  1910          Doc.bind(div, 'click', () => {
  1911            for (const bttn of (Array.from(page.unifiedReceivers.children) as PageElement[])) bttn.classList.toggle('selected', bttn.dataset.type === recvType)
  1912            this.setCentralAddress(recv)
  1913          })
  1914        }
  1915        addr = receivers.unified
  1916      }
  1917  
  1918      this.setCentralAddress(addr)
  1919    }
  1920  
  1921    setCentralAddress (addr: string) {
  1922      const page = this.page
  1923      page.depositAddress.textContent = addr
  1924      page.qrcode.src = `/generateqrcode?address=${addr}`
  1925    }
  1926  
  1927    /* Fetch a new address from the wallet. */
  1928    async newDepositAddress () {
  1929      const { page, assetID, form } = this
  1930      Doc.hide(page.depositErr)
  1931      const loaded = app().loading(form)
  1932      const res = await postJSON('/api/depositaddress', {
  1933        assetID: assetID
  1934      })
  1935      loaded()
  1936      if (!app().checkResponse(res)) {
  1937        page.depositErr.textContent = res.msg
  1938        Doc.show(page.depositErr)
  1939        return
  1940      }
  1941      app().walletMap[assetID].address = res.address
  1942      this.setAddress(res.address)
  1943    }
  1944  
  1945    async copyAddress () {
  1946      const page = this.page
  1947      navigator.clipboard.writeText(page.depositAddress.textContent || '')
  1948        .then(() => {
  1949          Doc.show(page.copyAlert)
  1950          setTimeout(() => {
  1951            Doc.hide(page.copyAlert)
  1952          }, 800)
  1953        })
  1954        .catch((reason) => {
  1955          console.error('Unable to copy: ', reason)
  1956        })
  1957    }
  1958  }
  1959  
  1960  // AppPassResetForm is used to reset the app apssword using the app seed.
  1961  export class AppPassResetForm {
  1962    form: PageElement
  1963    page: Record<string, PageElement>
  1964    success: () => void
  1965  
  1966    constructor (form: PageElement, success: () => void) {
  1967      this.form = form
  1968      this.success = success
  1969      const page = this.page = Doc.idDescendants(form)
  1970      bind(form, page.resetAppPWSubmitBtn, () => this.resetAppPW())
  1971    }
  1972  
  1973    async resetAppPW () {
  1974      const page = this.page
  1975      const newAppPW = page.newAppPassword.value || ''
  1976      const confirmNewAppPW = page.confirmNewAppPassword.value
  1977      if (newAppPW === '') {
  1978        Doc.showFormError(page.appPWResetErrMsg, intl.prep(intl.ID_NO_PASS_ERROR_MSG))
  1979        return
  1980      }
  1981      if (newAppPW !== confirmNewAppPW) {
  1982        Doc.showFormError(page.appPWResetErrMsg, intl.prep(intl.ID_PASSWORD_NOT_MATCH))
  1983        return
  1984      }
  1985  
  1986      const loaded = app().loading(this.form)
  1987      const res = await postJSON('/api/resetapppassword', {
  1988        newPass: newAppPW,
  1989        seed: page.seedInput.value
  1990      })
  1991      loaded()
  1992      if (!app().checkResponse(res)) {
  1993        Doc.showFormError(page.appPWResetErrMsg, res.msg)
  1994        return
  1995      }
  1996  
  1997      if (Doc.isDisplayed(page.appPWResetErrMsg)) Doc.hide(page.appPWResetErrMsg)
  1998      page.appPWResetSuccessMsg.textContent = intl.prep(intl.ID_PASSWORD_RESET_SUCCESS_MSG)
  1999      Doc.show(page.appPWResetSuccessMsg)
  2000      setTimeout(() => this.success(), 3000) // allow time to view the message
  2001    }
  2002  
  2003    focus () {
  2004      this.page.newAppPassword.focus()
  2005    }
  2006  
  2007    refresh () {
  2008      const page = this.page
  2009      page.newAppPassword.value = ''
  2010      page.confirmNewAppPassword.value = ''
  2011      page.seedInput.value = ''
  2012      Doc.hide(page.appPWResetSuccessMsg, page.appPWResetErrMsg)
  2013    }
  2014  }
  2015  
  2016  export class CertificatePicker {
  2017    page: Record<string, PageElement>
  2018  
  2019    constructor (parent: PageElement) {
  2020      const page = this.page = Doc.parseTemplate(parent)
  2021      page.selectedCert.textContent = intl.prep(intl.ID_NONE_SELECTED)
  2022      Doc.bind(page.certFile, 'change', () => this.onCertFileChange())
  2023      Doc.bind(page.removeCert, 'click', () => this.clearCertFile())
  2024      Doc.bind(page.addCert, 'click', () => page.certFile.click())
  2025    }
  2026  
  2027    /**
  2028     * onCertFileChange when the input certFile changed, read the file
  2029     * and setting cert name into text of selectedCert to display on the view
  2030     */
  2031    async onCertFileChange () {
  2032      const page = this.page
  2033      const files = page.certFile.files
  2034      if (!files || !files.length) return
  2035      page.selectedCert.textContent = files[0].name
  2036      Doc.show(page.removeCert)
  2037      Doc.hide(page.addCert)
  2038    }
  2039  
  2040    /* clearCertFile cleanup certFile value and selectedCert text */
  2041    clearCertFile () {
  2042      const page = this.page
  2043      page.certFile.value = ''
  2044      page.selectedCert.textContent = intl.prep(intl.ID_NONE_SELECTED)
  2045      Doc.hide(page.removeCert)
  2046      Doc.show(page.addCert)
  2047    }
  2048  
  2049    async file (): Promise<string> {
  2050      const page = this.page
  2051      if (page.certFile.value) {
  2052        const files = page.certFile.files
  2053        if (files && files.length) {
  2054          return await files[0].text()
  2055        }
  2056      }
  2057      return ''
  2058    }
  2059  }
  2060  
  2061  export class TokenApprovalForm {
  2062    page: Record<string, PageElement>
  2063    success?: () => void
  2064    assetID: number
  2065    parentID: number
  2066    txFee: number
  2067    host: string
  2068  
  2069    constructor (parent: PageElement, success?: () => void) {
  2070      this.page = Doc.parseTemplate(parent)
  2071      this.success = success
  2072      Doc.bind(this.page.submit, 'click', () => { this.approve() })
  2073    }
  2074  
  2075    async setAsset (assetID: number, host: string) {
  2076      this.assetID = assetID
  2077      this.host = host
  2078      const tokenAsset = app().assets[assetID]
  2079      const parentID = this.parentID = tokenAsset.token?.parentID as number
  2080      const { page } = this
  2081  
  2082      Doc.show(page.submissionElements)
  2083      Doc.hide(page.txMsg, page.errMsg, page.addressBox, page.balanceBox, page.addressBox)
  2084  
  2085      Doc.empty(page.tokenSymbol)
  2086      page.tokenSymbol.appendChild(Doc.symbolize(tokenAsset, true))
  2087      const protocolVersion = app().exchanges[host].assets[assetID].version
  2088      const res = await postJSON('/api/approvetokenfee', {
  2089        assetID: tokenAsset.id,
  2090        version: protocolVersion,
  2091        approving: true
  2092      })
  2093      if (!app().checkResponse(res)) {
  2094        page.errMsg.textContent = res.msg
  2095        Doc.show(page.errMsg)
  2096      } else {
  2097        const { unitInfo: ui, wallet: { address, balance: { available: avail } }, name: parentName } = app().assets[parentID]
  2098        const txFee = this.txFee = res.txFee as number
  2099        let feeText = `${Doc.formatCoinValue(txFee, ui)} ${ui.conventional.unit}`
  2100        const rate = app().fiatRatesMap[parentID]
  2101        if (rate) {
  2102          feeText += ` (${Doc.formatFiatConversion(txFee, rate, ui)} USD)`
  2103        }
  2104        page.feeEstimate.textContent = feeText
  2105        Doc.show(page.balanceBox)
  2106        page.balance.textContent = Doc.formatCoinValue(avail, ui)
  2107        page.parentTicker.textContent = ui.conventional.unit
  2108        page.parentName.textContent = parentName
  2109        if (avail < txFee) {
  2110          Doc.show(page.addressBox)
  2111          page.address.textContent = address
  2112        }
  2113      }
  2114    }
  2115  
  2116    /*
  2117     * approve calls the /api/approvetoken endpoint.
  2118     */
  2119    async approve () {
  2120      const { page, assetID, host, success } = this
  2121      const path = '/api/approvetoken'
  2122      const tokenAsset = app().assets[assetID]
  2123      const res = await postJSON(path, {
  2124        assetID: tokenAsset.id,
  2125        dexAddr: host
  2126      })
  2127      if (!app().checkResponse(res)) {
  2128        page.errMsg.textContent = res.msg
  2129        Doc.show(page.errMsg)
  2130        return
  2131      }
  2132      page.txid.innerText = res.txID
  2133      const assetExplorer = CoinExplorers[tokenAsset.id]
  2134      if (assetExplorer && assetExplorer[app().user.net]) {
  2135        page.txid.href = assetExplorer[app().user.net](res.txID)
  2136      }
  2137      Doc.hide(page.submissionElements, page.balanceBox, page.addressBox)
  2138      Doc.show(page.txMsg)
  2139      if (success) success()
  2140    }
  2141  
  2142    handleBalanceNote (n: BalanceNote) {
  2143      const { page, parentID, txFee } = this
  2144      if (n.assetID !== parentID) return
  2145      page.balance.textContent = Doc.formatCoinValue(n.balance.available, app().assets[parentID].unitInfo)
  2146      if (n.balance.available >= txFee) {
  2147        Doc.hide(page.addressBox)
  2148      } else Doc.hide(page.errMsg)
  2149    }
  2150  }
  2151  
  2152  export class CEXConfigurationForm {
  2153    form: PageElement
  2154    page: Record<string, PageElement>
  2155    updated: (cexName: string, success: boolean) => void
  2156    cexName: string
  2157  
  2158    constructor (form: PageElement, updated: (cexName: string, success: boolean) => void) {
  2159      this.form = form
  2160      this.updated = updated
  2161      this.page = Doc.parseTemplate(form)
  2162      Doc.bind(this.page.cexSubmit, 'click', () => this.submit())
  2163    }
  2164  
  2165    setCEX (cexName: string) {
  2166      this.cexName = cexName
  2167      setCexElements(this.form, cexName)
  2168      const page = this.page
  2169      Doc.hide(page.cexConfigPrompt, page.cexConnectErrBox, page.cexFormErr)
  2170      page.cexApiKeyInput.value = ''
  2171      page.cexSecretInput.value = ''
  2172      const cexStatus = app().mmStatus.cexes[cexName]
  2173      const connectErr = cexStatus?.connectErr
  2174      if (connectErr) {
  2175        Doc.show(page.cexConnectErrBox)
  2176        page.cexConnectErr.textContent = connectErr
  2177        page.cexApiKeyInput.value = cexStatus.config.apiKey
  2178        page.cexSecretInput.value = cexStatus.config.apiSecret
  2179      } else {
  2180        Doc.show(page.cexConfigPrompt)
  2181      }
  2182    }
  2183  
  2184    /*
  2185    * handleCEXSubmit handles clicks on the CEX configuration submission button.
  2186    */
  2187    async submit () {
  2188      const { page, cexName, form } = this
  2189      Doc.hide(page.cexFormErr)
  2190      const apiKey = page.cexApiKeyInput.value
  2191      const apiSecret = page.cexSecretInput.value
  2192      if (!apiKey || !apiSecret) {
  2193        Doc.show(page.cexFormErr)
  2194        page.cexFormErr.textContent = intl.prep(intl.ID_NO_PASS_ERROR_MSG)
  2195        return
  2196      }
  2197      const loaded = app().loading(form)
  2198      try {
  2199        const res = await MM.updateCEXConfig({
  2200          name: cexName,
  2201          apiKey: apiKey,
  2202          apiSecret: apiSecret
  2203        })
  2204        if (!app().checkResponse(res)) throw res
  2205        this.updated(cexName, true)
  2206      } catch (e) {
  2207        Doc.show(page.cexFormErr)
  2208        page.cexFormErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg ?? String(e) })
  2209        this.updated(cexName, false)
  2210      } finally {
  2211        loaded()
  2212      }
  2213    }
  2214  }
  2215  
  2216  const animationLength = 300
  2217  
  2218  /* Swap form1 for form2 with an animation. */
  2219  export async function slideSwap (form1: HTMLElement, form2: HTMLElement) {
  2220    const shift = document.body.offsetWidth / 2
  2221    await Doc.animate(animationLength, progress => {
  2222      form1.style.right = `${progress * shift}px`
  2223    }, 'easeInHard')
  2224    Doc.hide(form1)
  2225    form1.style.right = '0'
  2226    form2.style.right = String(-shift)
  2227    Doc.show(form2)
  2228    if (form2.querySelector('input')) {
  2229      Doc.safeSelector(form2, 'input').focus()
  2230    }
  2231    await Doc.animate(animationLength, progress => {
  2232      form2.style.right = `${-shift + progress * shift}px`
  2233    }, 'easeOutHard')
  2234    form2.style.right = '0'
  2235  }
  2236  
  2237  export function showSuccess (page: Record<string, PageElement>, msg: string) {
  2238    page.successMessage.textContent = msg
  2239    Doc.show(page.forms, page.checkmarkForm)
  2240    page.checkmarkForm.style.right = '0'
  2241    page.checkmark.style.fontSize = '0px'
  2242  
  2243    const [startR, startG, startB] = State.isDark() ? [223, 226, 225] : [51, 51, 51]
  2244    const [endR, endG, endB] = [16, 163, 16]
  2245    const [diffR, diffG, diffB] = [endR - startR, endG - startG, endB - startB]
  2246  
  2247    return new Animation(1200, (prog: number) => {
  2248      page.checkmark.style.fontSize = `${prog * 80}px`
  2249      page.checkmark.style.color = `rgb(${startR + prog * diffR}, ${startG + prog * diffG}, ${startB + prog * diffB})`
  2250    }, 'easeOutElastic')
  2251  }
  2252  
  2253  /*
  2254   * bind binds the click and submit events and prevents page reloading on
  2255   * submission.
  2256   */
  2257  export function bind (form: HTMLElement, submitBttn: HTMLElement, handler: (e: Event) => void) {
  2258    const wrapper = (e: Event) => {
  2259      if (e.preventDefault) e.preventDefault()
  2260      handler(e)
  2261    }
  2262    Doc.bind(submitBttn, 'click', wrapper)
  2263    Doc.bind(form, 'submit', wrapper)
  2264  }
  2265  
  2266  // isTruthyString will be true if the provided string is recognized as a
  2267  // value representing true.
  2268  function isTruthyString (s: string) {
  2269    return s === '1' || s.toLowerCase() === 'true'
  2270  }
  2271  
  2272  // toUnixDate converts a javascript date object to a unix date, which is
  2273  // the number of *seconds* since the start of the epoch.
  2274  function toUnixDate (date: Date) {
  2275    return Math.floor(date.getTime() / 1000)
  2276  }
  2277  
  2278  // dateApplyOffset shifts a date by the timezone offset. This is used to make
  2279  // UTC dates show the local date. This can be used to prepare a Date so
  2280  // toISOString generates a local date string. This is also used to trick an html
  2281  // input element to show the local date when setting the valueAsDate field. When
  2282  // reading the date back to JS, the value field should be interpreted as local
  2283  // using the "T00:00" suffix, or the Date in valueAsDate should be shifted in
  2284  // the opposite direction.
  2285  function dateApplyOffset (date: Date) {
  2286    return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000)
  2287  }
  2288  
  2289  // dateToString converts a javascript date object to a YYYY-MM-DD format string,
  2290  // in the local time zone.
  2291  function dateToString (date: Date) {
  2292    return dateApplyOffset(date).toISOString().split('T')[0]
  2293    // Another common hack:
  2294    // date.toLocaleString("sv-SE", { year: "numeric", month: "2-digit", day: "2-digit" })
  2295  }