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