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 }