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