decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/markets.ts (about) 1 import Doc, { WalletIcons, parseFloatDefault } from './doc' 2 import State from './state' 3 import BasePage from './basepage' 4 import OrderBook from './orderbook' 5 import { ReputationMeter, tradingLimits, strongTier } from './account' 6 import { 7 CandleChart, 8 DepthChart, 9 DepthLine, 10 CandleReporters, 11 MouseReport, 12 VolumeReport, 13 DepthMarker, 14 Wave 15 } from './charts' 16 import { postJSON } from './http' 17 import { 18 NewWalletForm, 19 AccelerateOrderForm, 20 DepositAddress, 21 TokenApprovalForm, 22 bind as bindForm, 23 Forms 24 } from './forms' 25 import * as OrderUtil from './orderutil' 26 import ws from './ws' 27 import * as intl from './locales' 28 import { 29 app, 30 SupportedAsset, 31 PageElement, 32 Order, 33 Market, 34 OrderEstimate, 35 MaxOrderEstimate, 36 Exchange, 37 UnitInfo, 38 Asset, 39 Candle, 40 CandlesPayload, 41 TradeForm, 42 BookUpdate, 43 MaxSell, 44 MaxBuy, 45 SwapEstimate, 46 MarketOrderBook, 47 APIResponse, 48 PreSwap, 49 PreRedeem, 50 WalletStateNote, 51 WalletSyncNote, 52 WalletCreationNote, 53 SpotPriceNote, 54 BondNote, 55 OrderNote, 56 EpochNote, 57 BalanceNote, 58 MiniOrder, 59 RemainderUpdate, 60 ConnEventNote, 61 OrderOption, 62 ConnectionStatus, 63 RecentMatch, 64 MatchNote, 65 ApprovalStatus, 66 OrderFilter, 67 RunStatsNote, 68 RunEventNote, 69 EpochReportNote, 70 CEXProblemsNote 71 } from './registry' 72 import { setOptionTemplates } from './opts' 73 import { RunningMarketMakerDisplay, RunningMMDisplayElements } from './mmutil' 74 75 const bind = Doc.bind 76 77 const bookRoute = 'book' 78 const bookOrderRoute = 'book_order' 79 const unbookOrderRoute = 'unbook_order' 80 const updateRemainingRoute = 'update_remaining' 81 const epochOrderRoute = 'epoch_order' 82 const candlesRoute = 'candles' 83 const candleUpdateRoute = 'candle_update' 84 const unmarketRoute = 'unmarket' 85 const epochMatchSummaryRoute = 'epoch_match_summary' 86 87 const anHour = 60 * 60 * 1000 // milliseconds 88 const maxUserOrdersShown = 10 89 90 const buyBtnClass = 'buygreen-bg' 91 const sellBtnClass = 'sellred-bg' 92 93 const fiveMinBinKey = '5m' 94 const oneHrBinKey = '1h' 95 96 const percentFormatter = new Intl.NumberFormat(Doc.languages(), { 97 minimumFractionDigits: 1, 98 maximumFractionDigits: 2 99 }) 100 101 const parentIDNone = 0xFFFFFFFF 102 103 interface MetaOrder { 104 div: HTMLElement 105 header: Record<string, PageElement> 106 details: Record<string, PageElement> 107 ord: Order 108 cancelling?: boolean 109 } 110 111 interface CancelData { 112 bttn: PageElement 113 order: Order 114 } 115 116 interface CurrentMarket { 117 dex: Exchange 118 sid: string // A string market identifier used by the DEX. 119 cfg: Market 120 base: SupportedAsset 121 quote: SupportedAsset 122 baseUnitInfo: UnitInfo 123 quoteUnitInfo: UnitInfo 124 maxSellRequested: boolean 125 maxSell: MaxOrderEstimate | null 126 sellBalance: number 127 buyBalance: number 128 maxBuys: Record<number, MaxOrderEstimate> 129 candleCaches: Record<string, CandlesPayload> 130 baseCfg: Asset 131 quoteCfg: Asset 132 rateConversionFactor: number 133 bookLoaded: boolean 134 } 135 136 interface LoadTracker { 137 loaded: () => void 138 timer: number 139 } 140 141 interface OrderRow extends HTMLElement { 142 manager: OrderTableRowManager 143 } 144 145 interface StatsDisplay { 146 row: PageElement 147 tmpl: Record<string, PageElement> 148 } 149 150 interface MarketsPageParams { 151 host: string 152 baseID: string 153 quoteID: string 154 } 155 156 export default class MarketsPage extends BasePage { 157 page: Record<string, PageElement> 158 main: HTMLElement 159 maxLoaded: (() => void) | null 160 maxOrderUpdateCounter: number 161 market: CurrentMarket 162 openAsset: SupportedAsset 163 currentCreate: SupportedAsset 164 maxEstimateTimer: number | null 165 book: OrderBook 166 cancelData: CancelData 167 metaOrders: Record<string, MetaOrder> 168 preorderCache: Record<string, OrderEstimate> 169 currentOrder: TradeForm 170 depthLines: Record<string, DepthLine[]> 171 activeMarkerRate: number | null 172 hovers: HTMLElement[] 173 ogTitle: string 174 depthChart: DepthChart 175 candleChart: CandleChart 176 candleDur: string 177 balanceWgt: BalanceWidget 178 mm: RunningMarketMakerDisplay 179 marketList: MarketList 180 newWalletForm: NewWalletForm 181 depositAddrForm: DepositAddress 182 approveTokenForm: TokenApprovalForm 183 reputationMeter: ReputationMeter 184 keyup: (e: KeyboardEvent) => void 185 secondTicker: number 186 candlesLoading: LoadTracker | null 187 accelerateOrderForm: AccelerateOrderForm 188 recentMatches: RecentMatch[] 189 recentMatchesSortKey: string 190 recentMatchesSortDirection: 1 | -1 191 stats: [StatsDisplay, StatsDisplay] 192 loadingAnimations: { candles?: Wave, depth?: Wave } 193 mmRunning: boolean | undefined 194 forms: Forms 195 constructor (main: HTMLElement, pageParams: MarketsPageParams) { 196 super() 197 198 const page = this.page = Doc.idDescendants(main) 199 this.main = main 200 if (!this.main.parentElement) return // Not gonna happen, but TypeScript cares. 201 // There may be multiple pending updates to the max order. This makes sure 202 // that the screen is updated with the most recent one. 203 this.maxOrderUpdateCounter = 0 204 this.metaOrders = {} 205 this.recentMatches = [] 206 this.preorderCache = {} 207 this.depthLines = { 208 hover: [], 209 input: [] 210 } 211 this.hovers = [] 212 // 'Recent Matches' list sort key and direction. 213 this.recentMatchesSortKey = 'age' 214 this.recentMatchesSortDirection = -1 215 // store original title so we can re-append it when updating market value. 216 this.ogTitle = document.title 217 this.forms = new Forms(page.forms, { 218 closed: (closedForm: PageElement | undefined) => { 219 if (closedForm === page.vDetailPane) { 220 this.showVerifyForm() 221 } 222 } 223 }) 224 225 const depthReporters = { 226 click: (x: number) => { this.reportDepthClick(x) }, 227 volume: (r: VolumeReport) => { this.reportDepthVolume(r) }, 228 mouse: (r: MouseReport) => { this.reportDepthMouse(r) }, 229 zoom: (z: number) => { this.reportDepthZoom(z) } 230 } 231 this.depthChart = new DepthChart(page.depthChart, depthReporters, State.fetchLocal(State.depthZoomLK)) 232 233 const candleReporters: CandleReporters = { 234 mouse: c => { this.reportMouseCandle(c) } 235 } 236 this.candleChart = new CandleChart(page.candlesChart, candleReporters) 237 238 const success = () => { /* do nothing */ } 239 // Do not call cleanTemplates before creating the AccelerateOrderForm 240 this.accelerateOrderForm = new AccelerateOrderForm(page.accelerateForm, success) 241 242 this.approveTokenForm = new TokenApprovalForm(page.approveTokenForm) 243 244 // Set user's last known candle duration. 245 this.candleDur = State.fetchLocal(State.lastCandleDurationLK) || oneHrBinKey 246 247 // Setup the register to trade button. 248 // TODO: Use dexsettings page? 249 const registerBttn = Doc.tmplElement(page.notRegistered, 'registerBttn') 250 bind(registerBttn, 'click', () => { 251 app().loadPage('register', { host: this.market.dex.host }) 252 }) 253 254 // Set up the BalanceWidget. 255 { 256 page.walletInfoTmpl.removeAttribute('id') 257 const bWidget = page.walletInfoTmpl 258 const qWidget = page.walletInfoTmpl.cloneNode(true) as PageElement 259 bWidget.after(qWidget) 260 const wgt = this.balanceWgt = new BalanceWidget(bWidget, qWidget) 261 const baseIcons = wgt.base.stateIcons.icons 262 const quoteIcons = wgt.quote.stateIcons.icons 263 bind(wgt.base.tmpl.connect, 'click', () => { this.unlockWallet(this.market.base.id) }) 264 bind(wgt.quote.tmpl.connect, 'click', () => { this.unlockWallet(this.market.quote.id) }) 265 bind(wgt.base.tmpl.expired, 'click', () => { this.unlockWallet(this.market.base.id) }) 266 bind(wgt.quote.tmpl.expired, 'click', () => { this.unlockWallet(this.market.quote.id) }) 267 bind(baseIcons.sleeping, 'click', () => { this.unlockWallet(this.market.base.id) }) 268 bind(quoteIcons.sleeping, 'click', () => { this.unlockWallet(this.market.quote.id) }) 269 bind(baseIcons.locked, 'click', () => { this.unlockWallet(this.market.base.id) }) 270 bind(quoteIcons.locked, 'click', () => { this.unlockWallet(this.market.quote.id) }) 271 bind(baseIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.base) }) 272 bind(quoteIcons.disabled, 'click', () => { this.showToggleWalletStatus(this.market.quote) }) 273 bind(wgt.base.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.base) }) 274 bind(wgt.quote.tmpl.newWalletBttn, 'click', () => { this.showCreate(this.market.quote) }) 275 bind(wgt.base.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.base.id) }) 276 bind(wgt.quote.tmpl.walletAddr, 'click', () => { this.showDeposit(this.market.quote.id) }) 277 bind(wgt.base.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.base.id) }) 278 bind(wgt.quote.tmpl.wantProviders, 'click', () => { this.showCustomProviderDialog(this.market.quote.id) }) 279 this.depositAddrForm = new DepositAddress(page.deposit) 280 } 281 282 const runningMMDisplayElements: RunningMMDisplayElements = { 283 orderReportForm: page.orderReportForm, 284 dexBalancesRowTmpl: page.dexBalancesRowTmpl, 285 placementRowTmpl: page.placementRowTmpl, 286 placementAmtRowTmpl: page.placementAmtRowTmpl 287 } 288 Doc.cleanTemplates(page.dexBalancesRowTmpl, page.placementRowTmpl, page.placementAmtRowTmpl) 289 this.mm = new RunningMarketMakerDisplay(page.mmRunning, this.forms, runningMMDisplayElements, 'markets') 290 291 this.reputationMeter = new ReputationMeter(page.reputationMeter) 292 293 // Bind toggle wallet status form. 294 bindForm(page.toggleWalletStatusConfirm, page.toggleWalletStatusSubmit, async () => { this.toggleWalletStatus() }) 295 296 // Prepare templates for the buy and sell tables and the user's order table. 297 setOptionTemplates(page) 298 299 Doc.cleanTemplates( 300 page.orderRowTmpl, page.durBttnTemplate, page.booleanOptTmpl, page.rangeOptTmpl, 301 page.orderOptTmpl, page.userOrderTmpl, page.recentMatchesTemplate 302 ) 303 304 // Buttons to show token approval form 305 bind(page.approveBaseBttn, 'click', () => { this.showTokenApprovalForm(true) }) 306 bind(page.approveQuoteBttn, 'click', () => { this.showTokenApprovalForm(false) }) 307 308 const toggleTradingTier = (show: boolean) => { 309 Doc.setVis(!show, page.showTradingTier) 310 Doc.setVis(show, page.tradingLimits, page.hideTradingTier) 311 } 312 bind(page.showTradingTier, 'click', () => { toggleTradingTier(true) }) 313 bind(page.hideTradingTier, 'click', () => { toggleTradingTier(false) }) 314 315 const toggleTradingReputation = (show: boolean) => { 316 Doc.setVis(!show, page.showTradingReputation) 317 Doc.setVis(show, page.reputationMeter, page.hideTradingReputation) 318 } 319 bind(page.showTradingReputation, 'click', () => { toggleTradingReputation(true) }) 320 bind(page.hideTradingReputation, 'click', () => { toggleTradingReputation(false) }) 321 322 // Buttons to set order type and side. 323 bind(page.buyBttn, 'click', () => { this.setBuy() }) 324 bind(page.sellBttn, 'click', () => { this.setSell() }) 325 326 bind(page.limitBttn, 'click', () => { 327 swapBttns(page.marketBttn, page.limitBttn) 328 this.setOrderVisibility() 329 if (!page.rateField.value) return 330 this.depthLines.input = [{ 331 rate: parseFloatDefault(page.rateField.value, 0), 332 color: this.isSell() ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine 333 }] 334 this.drawChartLines() 335 }) 336 bind(page.marketBttn, 'click', () => { 337 swapBttns(page.limitBttn, page.marketBttn) 338 this.setOrderVisibility() 339 this.setMarketBuyOrderEstimate() 340 this.depthLines.input = [] 341 this.drawChartLines() 342 }) 343 bind(page.maxOrd, 'click', () => { 344 if (this.isSell()) { 345 const maxSell = this.market.maxSell 346 if (!maxSell) return 347 page.lotField.value = String(maxSell.swap.lots) 348 } else { 349 const maxBuy = this.market.maxBuys[this.adjustedRate()] 350 if (!maxBuy) return 351 page.lotField.value = String(maxBuy.swap.lots) 352 } 353 this.lotChanged() 354 }) 355 356 Doc.disableMouseWheel(page.rateField, page.lotField, page.qtyField, page.mktBuyField) 357 358 // Handle the full orderbook sent on the 'book' route. 359 ws.registerRoute(bookRoute, (data: BookUpdate) => { this.handleBookRoute(data) }) 360 // Handle the new order for the order book on the 'book_order' route. 361 ws.registerRoute(bookOrderRoute, (data: BookUpdate) => { this.handleBookOrderRoute(data) }) 362 // Remove the order sent on the 'unbook_order' route from the orderbook. 363 ws.registerRoute(unbookOrderRoute, (data: BookUpdate) => { this.handleUnbookOrderRoute(data) }) 364 // Update the remaining quantity on a booked order. 365 ws.registerRoute(updateRemainingRoute, (data: BookUpdate) => { this.handleUpdateRemainingRoute(data) }) 366 // Handle the new order for the order book on the 'epoch_order' route. 367 ws.registerRoute(epochOrderRoute, (data: BookUpdate) => { this.handleEpochOrderRoute(data) }) 368 // Handle the initial candlestick data on the 'candles' route. 369 ws.registerRoute(candlesRoute, (data: BookUpdate) => { this.handleCandlesRoute(data) }) 370 // Handle the candles update on the 'candles' route. 371 ws.registerRoute(candleUpdateRoute, (data: BookUpdate) => { this.handleCandleUpdateRoute(data) }) 372 373 // Handle the recent matches update on the 'epoch_report' route. 374 ws.registerRoute(epochMatchSummaryRoute, (data: BookUpdate) => { this.handleEpochMatchSummary(data) }) 375 // Create a wallet 376 this.newWalletForm = new NewWalletForm(page.newWalletForm, async () => { this.createWallet() }) 377 // Main order form. 378 bindForm(page.orderForm, page.submitBttn, async () => { this.stepSubmit() }) 379 // Order verification form. 380 bindForm(page.verifyForm, page.vSubmit, async () => { this.submitOrder() }) 381 // Cancel order form. 382 bindForm(page.cancelForm, page.cancelSubmit, async () => { this.submitCancel() }) 383 // Order detail view. 384 Doc.bind(page.vFeeDetails, 'click', () => this.forms.show(page.vDetailPane)) 385 Doc.bind(page.closeDetailPane, 'click', () => this.showVerifyForm()) 386 // // Bind active orders list's header sort events. 387 page.recentMatchesTable.querySelectorAll('[data-ordercol]') 388 .forEach((th: HTMLElement) => bind( 389 th, 'click', () => setRecentMatchesSortCol(th.dataset.ordercol || '') 390 )) 391 392 const setRecentMatchesSortCol = (key: string) => { 393 // First unset header's current sorted col classes. 394 unsetRecentMatchesSortColClasses() 395 if (this.recentMatchesSortKey === key) { 396 this.recentMatchesSortDirection *= -1 397 } else { 398 this.recentMatchesSortKey = key 399 this.recentMatchesSortDirection = 1 400 } 401 this.refreshRecentMatchesTable() 402 setRecentMatchesSortColClasses() 403 } 404 405 // sortClassByDirection receives a sort direction and return a class based on it. 406 const sortClassByDirection = (element: 1 | -1) => { 407 if (element === 1) return 'sorted-asc' 408 return 'sorted-dsc' 409 } 410 411 const unsetRecentMatchesSortColClasses = () => { 412 page.recentMatchesTable.querySelectorAll('[data-ordercol]') 413 .forEach(th => th.classList.remove('sorted-asc', 'sorted-dsc')) 414 } 415 416 const setRecentMatchesSortColClasses = () => { 417 const key = this.recentMatchesSortKey 418 const sortCls = sortClassByDirection(this.recentMatchesSortDirection) 419 Doc.safeSelector(page.recentMatchesTable, `[data-ordercol=${key}]`).classList.add(sortCls) 420 } 421 422 // Set default's sorted col header classes. 423 setRecentMatchesSortColClasses() 424 425 const closePopups = () => { 426 this.forms.close() 427 } 428 429 this.keyup = (e: KeyboardEvent) => { 430 if (e.key === 'Escape') { 431 closePopups() 432 } 433 } 434 bind(document, 'keyup', this.keyup) 435 436 page.forms.querySelectorAll('.form-closer').forEach(el => { 437 Doc.bind(el, 'click', () => { closePopups() }) 438 }) 439 440 // Event listeners for interactions with the various input fields. 441 bind(page.lotField, ['change', 'keyup'], () => { this.lotChanged() }) 442 bind(page.qtyField, 'change', () => { this.quantityChanged(true) }) 443 bind(page.qtyField, 'keyup', () => { this.quantityChanged(false) }) 444 bind(page.mktBuyField, ['change', 'keyup'], () => { this.marketBuyChanged() }) 445 bind(page.rateField, 'change', () => { this.rateFieldChanged() }) 446 bind(page.rateField, 'keyup', () => { this.previewQuoteAmt(true) }) 447 448 // Market search input bindings. 449 bind(page.marketSearchV1, ['change', 'keyup'], () => { this.filterMarkets() }) 450 451 // Acknowledge the order disclaimer. 452 const setDisclaimerAckViz = (acked: boolean) => { 453 Doc.setVis(!acked, page.disclaimer, page.disclaimerAck) 454 Doc.setVis(acked, page.showDisclaimer) 455 } 456 bind(page.disclaimerAck, 'click', () => { 457 State.storeLocal(State.orderDisclaimerAckedLK, true) 458 setDisclaimerAckViz(true) 459 }) 460 bind(page.showDisclaimer, 'click', () => { 461 State.storeLocal(State.orderDisclaimerAckedLK, false) 462 setDisclaimerAckViz(false) 463 }) 464 setDisclaimerAckViz(State.fetchLocal(State.orderDisclaimerAckedLK)) 465 466 const clearChartLines = () => { 467 this.depthLines.hover = [] 468 this.drawChartLines() 469 } 470 bind(page.buyRows, 'mouseleave', clearChartLines) 471 bind(page.sellRows, 'mouseleave', clearChartLines) 472 bind(page.userOrders, 'mouseleave', () => { 473 this.activeMarkerRate = null 474 this.setDepthMarkers() 475 }) 476 477 const stats0 = page.marketStats 478 const stats1 = stats0.cloneNode(true) as PageElement 479 stats1.classList.add('listopen') 480 Doc.hide(stats0, stats1) 481 stats1.removeAttribute('id') 482 app().headerSpace.appendChild(stats1) 483 this.stats = [{ row: stats0, tmpl: Doc.parseTemplate(stats0) }, { row: stats1, tmpl: Doc.parseTemplate(stats1) }] 484 485 const closeMarketsList = () => { 486 State.storeLocal(State.leftMarketDockLK, '0') 487 page.leftMarketDock.classList.remove('default') 488 page.leftMarketDock.classList.add('stashed') 489 for (const s of this.stats) s.row.classList.remove('listopen') 490 } 491 const openMarketsList = () => { 492 State.storeLocal(State.leftMarketDockLK, '1') 493 page.leftMarketDock.classList.remove('default', 'stashed') 494 for (const s of this.stats) s.row.classList.add('listopen') 495 } 496 Doc.bind(page.leftHider, 'click', () => closeMarketsList()) 497 Doc.bind(page.marketReopener, 'click', () => openMarketsList()) 498 for (const s of this.stats) { 499 Doc.bind(s.tmpl.marketSelect, 'click', () => { 500 if (page.leftMarketDock.clientWidth === 0) openMarketsList() 501 else closeMarketsList() 502 }) 503 } 504 this.marketList = new MarketList(page.marketListV1) 505 // Prepare the list of markets. 506 for (const row of this.marketList.markets) { 507 bind(row.node, 'click', () => { 508 // return early if the market is already set 509 const { quoteid: quoteID, baseid: baseID, xc: { host } } = row.mkt 510 if (this.market?.base?.id === baseID && this.market?.quote?.id === quoteID) return 511 this.startLoadingAnimations() 512 this.setMarket(host, baseID, quoteID) 513 }) 514 } 515 if (State.fetchLocal(State.leftMarketDockLK) !== '1') { // It is shown by default, hiding if necessary. 516 closeMarketsList() 517 } 518 519 // Notification filters. 520 app().registerNoteFeeder({ 521 order: (note: OrderNote) => { this.handleOrderNote(note) }, 522 match: (note: MatchNote) => { this.handleMatchNote(note) }, 523 epoch: (note: EpochNote) => { this.handleEpochNote(note) }, 524 conn: (note: ConnEventNote) => { this.handleConnNote(note) }, 525 balance: (note: BalanceNote) => { this.handleBalanceNote(note) }, 526 bondpost: (note: BondNote) => { this.handleBondUpdate(note) }, 527 spots: (note: SpotPriceNote) => { this.handlePriceUpdate(note) }, 528 walletstate: (note: WalletStateNote) => { this.handleWalletState(note) }, 529 reputation: () => { this.updateReputation() }, 530 feepayment: () => { this.updateReputation() }, 531 runstats: (note: RunStatsNote) => { 532 if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return 533 this.mm.update() 534 if (Boolean(this.mmRunning) !== Boolean(note.stats)) { 535 this.mmRunning = Boolean(note.stats) 536 this.resolveOrderFormVisibility() 537 } 538 }, 539 epochreport: (note: EpochReportNote) => { 540 if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return 541 this.mm.handleEpochReportNote(note) 542 }, 543 cexproblems: (note: CEXProblemsNote) => { 544 if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return 545 this.mm.handleCexProblemsNote(note) 546 }, 547 runevent: (note: RunEventNote) => { 548 if (note.baseID !== this.market.base.id || note.quoteID !== this.market.quote.id || note.host !== this.market.dex.host) return 549 this.mm.update() 550 } 551 }) 552 553 this.loadingAnimations = {} 554 this.startLoadingAnimations() 555 556 // Start a ticker to update time-since values. 557 this.secondTicker = window.setInterval(() => { 558 for (const mord of Object.values(this.metaOrders)) { 559 mord.details.age.textContent = Doc.timeSince(mord.ord.submitTime) 560 } 561 for (const td of Doc.applySelector(page.recentMatchesLiveList, '[data-tmpl=age]')) { 562 td.textContent = Doc.timeSince(parseFloat(td.dataset.sinceStamp ?? '0')) 563 } 564 }, 1000) 565 566 this.init(pageParams) 567 } 568 569 async init (pageParams?: MarketsPageParams) { 570 // Fetch the first market in the list, or the users last selected market, if 571 // it exists. 572 let selected 573 if (pageParams?.host) { 574 selected = makeMarket(pageParams.host, parseInt(pageParams.baseID), parseInt(pageParams.quoteID)) 575 } else { 576 selected = State.fetchLocal(State.lastMarketLK) 577 } 578 if (!selected || !this.marketList.exists(selected.host, selected.base, selected.quote)) { 579 const first = this.marketList.first() 580 if (first) selected = { host: first.mkt.xc.host, base: first.mkt.baseid, quote: first.mkt.quoteid } 581 } 582 if (selected) this.setMarket(selected.host, selected.base, selected.quote) 583 else this.balanceWgt.setBalanceVisibility(false) // no market to display balance widget for. 584 585 // set the initial state for the registration status 586 this.setRegistrationStatusVisibility() 587 } 588 589 startLoadingAnimations () { 590 const { page, loadingAnimations: anis, depthChart, candleChart } = this 591 depthChart.canvas.classList.add('invisible') 592 candleChart.canvas.classList.add('invisible') 593 if (anis.candles) anis.candles.stop() 594 anis.candles = new Wave(page.candlesChart, { message: intl.prep(intl.ID_CANDLES_LOADING) }) 595 if (anis.depth) anis.depth.stop() 596 anis.depth = new Wave(page.depthChart, { message: intl.prep(intl.ID_DEPTH_LOADING) }) 597 } 598 599 /* isSell is true if the user has selected sell in the order options. */ 600 isSell () { 601 return this.page.sellBttn.classList.contains('selected') 602 } 603 604 /* isLimit is true if the user has selected the "limit order" tab. */ 605 isLimit () { 606 return this.page.limitBttn.classList.contains('selected') 607 } 608 609 setBuy () { 610 const { page } = this 611 swapBttns(page.sellBttn, page.buyBttn) 612 page.submitBttn.classList.remove(sellBtnClass) 613 page.submitBttn.classList.add(buyBtnClass) 614 page.maxLbl.textContent = intl.prep(intl.ID_BUY) 615 this.setOrderBttnText() 616 this.setOrderVisibility() 617 this.drawChartLines() 618 if (!this.isLimit()) { 619 this.marketBuyChanged() 620 } else { 621 this.currentOrder = this.parseOrder() 622 this.updateOrderBttnState() 623 } 624 } 625 626 setSell () { 627 const { page } = this 628 swapBttns(page.buyBttn, page.sellBttn) 629 page.submitBttn.classList.add(sellBtnClass) 630 page.submitBttn.classList.remove(buyBtnClass) 631 page.maxLbl.textContent = intl.prep(intl.ID_SELL) 632 this.setOrderBttnText() 633 this.setOrderVisibility() 634 this.drawChartLines() 635 this.currentOrder = this.parseOrder() 636 this.updateOrderBttnState() 637 } 638 639 /* hasPendingBonds is true if there are pending bonds */ 640 hasPendingBonds (): boolean { 641 return Object.keys(this.market.dex.auth.pendingBonds || []).length > 0 642 } 643 644 /* setCurrMarketPrice updates the current market price on the stats displays 645 and the orderbook display. */ 646 setCurrMarketPrice (): void { 647 const selected = this.market 648 if (!selected) return 649 // Get an up-to-date Market. 650 const xc = app().exchanges[selected.dex.host] 651 const mkt = xc.markets[selected.cfg.name] 652 if (!mkt.spot) return 653 654 for (const s of this.stats) { 655 const { unitInfo: { conventional: { conversionFactor: cFactor, unit } } } = xc.assets[mkt.baseid] 656 const fiatRate = app().fiatRatesMap[mkt.baseid] 657 if (fiatRate) { 658 s.tmpl.volume.textContent = Doc.formatFourSigFigs(mkt.spot.vol24 / cFactor * fiatRate) 659 s.tmpl.volUnit.textContent = 'USD' 660 } else { 661 s.tmpl.volume.textContent = Doc.formatFourSigFigs(mkt.spot.vol24 / cFactor) 662 s.tmpl.volUnit.textContent = unit 663 } 664 setPriceAndChange(s.tmpl, xc, mkt) 665 } 666 667 this.page.obPrice.textContent = Doc.formatFourSigFigs(mkt.spot.rate / this.market.rateConversionFactor) 668 this.page.obPrice.classList.remove('sellcolor', 'buycolor') 669 this.page.obPrice.classList.add(mkt.spot.change24 >= 0 ? 'buycolor' : 'sellcolor') 670 Doc.setVis(mkt.spot.change24 >= 0, this.page.obUp) 671 Doc.setVis(mkt.spot.change24 < 0, this.page.obDown) 672 } 673 674 /* setMarketDetails updates the currency names on the stats displays. */ 675 setMarketDetails () { 676 if (!this.market) return 677 for (const s of this.stats) { 678 const { baseCfg: ba, quoteCfg: qa } = this.market 679 s.tmpl.baseIcon.src = Doc.logoPath(ba.symbol) 680 s.tmpl.quoteIcon.src = Doc.logoPath(qa.symbol) 681 Doc.empty(s.tmpl.baseSymbol, s.tmpl.quoteSymbol) 682 s.tmpl.baseSymbol.appendChild(Doc.symbolize(ba, true)) 683 s.tmpl.quoteSymbol.appendChild(Doc.symbolize(qa, true)) 684 } 685 } 686 687 /* setHighLow calculates the high and low rates over the last 24 hours. */ 688 setHighLow () { 689 let [high, low] = [0, 0] 690 const spot = this.market.cfg.spot 691 // Use spot values for 24 hours high and low rates if it is available. We 692 // will default to setting it from candles if it's not. 693 if (spot && spot.low24 && spot.high24) { 694 high = spot.high24 695 low = spot.low24 696 } else { 697 const cache = this.market?.candleCaches[fiveMinBinKey] 698 if (!cache) { 699 if (this.candleDur !== fiveMinBinKey) { 700 this.requestCandles(fiveMinBinKey) 701 return 702 } 703 for (const s of this.stats) { 704 s.tmpl.high.textContent = '-' 705 s.tmpl.low.textContent = '-' 706 } 707 return 708 } 709 710 // Set high and low rates from candles. 711 const aDayAgo = new Date().getTime() - 86400000 712 for (let i = cache.candles.length - 1; i >= 0; i--) { 713 const c = cache.candles[i] 714 if (c.endStamp < aDayAgo) break 715 if (low === 0 || (c.lowRate > 0 && c.lowRate < low)) low = c.lowRate 716 if (c.highRate > high) high = c.highRate 717 } 718 } 719 720 const baseID = this.market.base.id 721 const quoteID = this.market.quote.id 722 const dex = this.market.dex 723 for (const s of this.stats) { 724 s.tmpl.high.textContent = high > 0 ? Doc.formatFourSigFigs(app().conventionalRate(baseID, quoteID, high, dex)) : '-' 725 s.tmpl.low.textContent = low > 0 ? Doc.formatFourSigFigs(app().conventionalRate(baseID, quoteID, low, dex)) : '-' 726 } 727 } 728 729 /* assetsAreSupported is true if all the assets of the current market are 730 * supported 731 */ 732 assetsAreSupported (): { 733 isSupported: boolean; 734 text: string; 735 } { 736 const { market: { base, quote, baseCfg, quoteCfg } } = this 737 if (!base || !quote) { 738 const symbol = base ? quoteCfg.symbol : baseCfg.symbol 739 return { 740 isSupported: false, 741 text: intl.prep(intl.ID_NOT_SUPPORTED, { asset: symbol.toUpperCase() }) 742 } 743 } 744 // check if versions are supported. If asset is a token, we check if its 745 // parent supports the version. 746 const bVers = (base.token ? app().assets[base.token.parentID].info?.versions : base.info?.versions) as number[] 747 const qVers = (quote.token ? app().assets[quote.token.parentID].info?.versions : quote.info?.versions) as number[] 748 // if none them are token, just check if own asset is supported. 749 let text = '' 750 if (!bVers.includes(baseCfg.version)) { 751 text = intl.prep(intl.ID_VERSION_NOT_SUPPORTED, { asset: base.symbol.toUpperCase(), version: baseCfg.version + '' }) 752 } else if (!qVers.includes(quoteCfg.version)) { 753 text = intl.prep(intl.ID_VERSION_NOT_SUPPORTED, { asset: quote.symbol.toUpperCase(), version: quoteCfg.version + '' }) 754 } 755 return { 756 isSupported: bVers.includes(baseCfg.version) && qVers.includes(quoteCfg.version), 757 text 758 } 759 } 760 761 /* 762 * setOrderVisibility sets which form is visible based on the specified 763 * options. 764 */ 765 setOrderVisibility () { 766 const page = this.page 767 if (this.isLimit()) { 768 Doc.show(page.priceBox, page.tifBox, page.qtyBox, page.maxBox) 769 Doc.hide(page.mktBuyBox) 770 this.previewQuoteAmt(true) 771 } else { 772 Doc.hide(page.tifBox, page.maxBox, page.priceBox) 773 if (this.isSell()) { 774 Doc.hide(page.mktBuyBox) 775 Doc.show(page.qtyBox) 776 this.previewQuoteAmt(true) 777 } else { 778 Doc.show(page.mktBuyBox) 779 Doc.hide(page.qtyBox) 780 this.previewQuoteAmt(false) 781 } 782 } 783 this.updateOrderBttnState() 784 } 785 786 /* resolveOrderFormVisibility displays or hides the 'orderForm' based on 787 * a set of conditions to be met. 788 */ 789 async resolveOrderFormVisibility () { 790 const page = this.page 791 792 const showOrderForm = async () : Promise<boolean> => { 793 if (!this.assetsAreSupported().isSupported) return false // assets not supported 794 795 if (!this.market || this.market.dex.auth.effectiveTier < 1) return false// acct suspended or not registered 796 797 const { baseAssetApprovalStatus, quoteAssetApprovalStatus } = this.tokenAssetApprovalStatuses() 798 if (baseAssetApprovalStatus !== ApprovalStatus.Approved || quoteAssetApprovalStatus !== ApprovalStatus.Approved) return false 799 800 const { base, quote } = this.market 801 const hasWallets = base && app().assets[base.id].wallet && quote && app().assets[quote.id].wallet 802 if (!hasWallets) return false 803 if (this.mmRunning) return false 804 return true 805 } 806 807 Doc.setVis(await showOrderForm(), page.orderForm, page.orderTypeBttns) 808 809 if (this.market) { 810 const { auth: { effectiveTier, pendingStrength } } = this.market.dex 811 Doc.setVis(effectiveTier > 0 || pendingStrength > 0, page.reputationAndTradingTierBox) 812 } 813 814 const mmStatus = app().mmStatus 815 if (mmStatus && this.mmRunning === undefined && this.market.base && this.market.quote) { 816 const { base: { id: baseID }, quote: { id: quoteID }, dex: { host } } = this.market 817 const botStatus = mmStatus.bots.find(({ config: cfg }) => cfg.baseID === baseID && cfg.quoteID === quoteID && cfg.host === host) 818 this.mmRunning = Boolean(botStatus?.running) 819 } 820 821 Doc.setVis(this.mmRunning, page.mmRunning) 822 if (this.mmRunning) Doc.hide(page.orderForm, page.orderTypeBttns) 823 } 824 825 /* setLoaderMsgVisibility displays a message in case a dex asset is not 826 * supported 827 */ 828 setLoaderMsgVisibility () { 829 const { page } = this 830 831 const { isSupported, text } = this.assetsAreSupported() 832 if (isSupported) { 833 // make sure to hide the loader msg 834 Doc.hide(page.loaderMsg) 835 return 836 } 837 page.loaderMsg.textContent = text 838 Doc.show(page.loaderMsg) 839 Doc.hide(page.notRegistered) 840 Doc.hide(page.noWallet) 841 } 842 843 /* 844 * showTokenApprovalForm displays the form used to give allowance to the 845 * swap contract of a token. 846 */ 847 async showTokenApprovalForm (isBase: boolean) { 848 const assetID = isBase ? this.market.base.id : this.market.quote.id 849 this.approveTokenForm.setAsset(assetID, this.market.dex.host) 850 this.forms.show(this.page.approveTokenForm) 851 } 852 853 /* 854 * tokenAssetApprovalStatuses returns the approval status of the base and 855 * quote assets. If the asset is not a token, it is considered approved. 856 */ 857 tokenAssetApprovalStatuses (): { 858 baseAssetApprovalStatus: ApprovalStatus; 859 quoteAssetApprovalStatus: ApprovalStatus; 860 } { 861 const { market: { base, quote } } = this 862 let baseAssetApprovalStatus = ApprovalStatus.Approved 863 let quoteAssetApprovalStatus = ApprovalStatus.Approved 864 865 if (base?.token) { 866 const baseAsset = app().assets[base.id] 867 const baseVersion = this.market.dex.assets[base.id].version 868 if (baseAsset?.wallet?.approved && baseAsset.wallet.approved[baseVersion] !== undefined) { 869 baseAssetApprovalStatus = baseAsset.wallet.approved[baseVersion] 870 } 871 } 872 if (quote?.token) { 873 const quoteAsset = app().assets[quote.id] 874 const quoteVersion = this.market.dex.assets[quote.id].version 875 if (quoteAsset?.wallet?.approved && quoteAsset.wallet.approved[quoteVersion] !== undefined) { 876 quoteAssetApprovalStatus = quoteAsset.wallet.approved[quoteVersion] 877 } 878 } 879 880 return { 881 baseAssetApprovalStatus, 882 quoteAssetApprovalStatus 883 } 884 } 885 886 /* 887 * setTokenApprovalVisibility sets the visibility of the token approval 888 * panel elements. 889 */ 890 setTokenApprovalVisibility () { 891 const { page } = this 892 893 const { baseAssetApprovalStatus, quoteAssetApprovalStatus } = this.tokenAssetApprovalStatuses() 894 895 if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) { 896 Doc.hide(page.tokenApproval) 897 page.sellBttn.removeAttribute('disabled') 898 page.buyBttn.removeAttribute('disabled') 899 return 900 } 901 902 if (baseAssetApprovalStatus !== ApprovalStatus.Approved && quoteAssetApprovalStatus === ApprovalStatus.Approved) { 903 page.sellBttn.setAttribute('disabled', 'disabled') 904 page.buyBttn.removeAttribute('disabled') 905 this.setBuy() 906 Doc.show(page.approvalRequiredSell) 907 Doc.hide(page.approvalRequiredBuy, page.approvalRequiredBoth) 908 } 909 910 if (baseAssetApprovalStatus === ApprovalStatus.Approved && quoteAssetApprovalStatus !== ApprovalStatus.Approved) { 911 page.buyBttn.setAttribute('disabled', 'disabled') 912 page.sellBttn.removeAttribute('disabled') 913 this.setSell() 914 Doc.show(page.approvalRequiredBuy) 915 Doc.hide(page.approvalRequiredSell, page.approvalRequiredBoth) 916 } 917 918 // If they are both unapproved tokens, the order form will not be shown. 919 if (baseAssetApprovalStatus !== ApprovalStatus.Approved && quoteAssetApprovalStatus !== ApprovalStatus.Approved) { 920 Doc.show(page.approvalRequiredBoth) 921 Doc.hide(page.approvalRequiredSell, page.approvalRequiredBuy) 922 } 923 924 Doc.show(page.tokenApproval) 925 page.approvalPendingBaseSymbol.textContent = page.baseTokenAsset.textContent = this.market.base.symbol.toUpperCase() 926 page.approvalPendingQuoteSymbol.textContent = page.quoteTokenAsset.textContent = this.market.quote.symbol.toUpperCase() 927 Doc.setVis(baseAssetApprovalStatus === ApprovalStatus.NotApproved, page.approveBaseBttn) 928 Doc.setVis(quoteAssetApprovalStatus === ApprovalStatus.NotApproved, page.approveQuoteBttn) 929 Doc.setVis(baseAssetApprovalStatus === ApprovalStatus.Pending, page.approvalPendingBase) 930 Doc.setVis(quoteAssetApprovalStatus === ApprovalStatus.Pending, page.approvalPendingQuote) 931 } 932 933 /* setRegistrationStatusView sets the text content and class for the 934 * registration status view 935 */ 936 setRegistrationStatusView (titleContent: string, confStatusMsg: string, titleClass: string) { 937 const page = this.page 938 page.regStatusTitle.textContent = titleContent 939 page.regStatusConfsDisplay.textContent = confStatusMsg 940 page.registrationStatus.classList.remove('completed', 'error', 'waiting') 941 page.registrationStatus.classList.add(titleClass) 942 } 943 944 /* 945 * updateRegistrationStatusView updates the view based on the current 946 * registration status 947 */ 948 updateRegistrationStatusView () { 949 const { page, market: { dex } } = this 950 page.regStatusDex.textContent = dex.host 951 page.postingBondsDex.textContent = dex.host 952 953 if (dex.auth.effectiveTier >= 1) { 954 this.setRegistrationStatusView(intl.prep(intl.ID_REGISTRATION_FEE_SUCCESS), '', 'completed') 955 return 956 } 957 958 const confStatuses = (dex.auth.pendingBonds || []).map(pending => { 959 const confirmationsRequired = dex.bondAssets[pending.symbol].confs 960 return `${pending.confs} / ${confirmationsRequired}` 961 }) 962 const confStatusMsg = confStatuses.join(', ') 963 this.setRegistrationStatusView(intl.prep(intl.ID_WAITING_FOR_CONFS), confStatusMsg, 'waiting') 964 } 965 966 /* 967 * setRegistrationStatusVisibility toggles the registration status view based 968 * on the dex data. 969 */ 970 setRegistrationStatusVisibility () { 971 const { page, market } = this 972 if (!market || !market.dex) return 973 974 // If dex is not connected to server, is not possible to know the 975 // registration status. 976 if (market.dex.connectionStatus !== ConnectionStatus.Connected) return 977 978 this.updateRegistrationStatusView() 979 980 const showSection = (section: PageElement | undefined) => { 981 const elements = [page.registrationStatus, page.bondRequired, page.bondCreationPending, page.notRegistered, page.penaltyCompsRequired] 982 for (const el of elements) { 983 Doc.setVis(el === section, el) 984 } 985 } 986 987 if (market.dex.auth.effectiveTier >= 1) { 988 const toggle = async () => { 989 showSection(undefined) 990 this.resolveOrderFormVisibility() 991 } 992 if (Doc.isHidden(page.orderForm)) { 993 // wait a couple of seconds before showing the form so the success 994 // message is shown to the user 995 setTimeout(toggle, 5000) 996 return 997 } 998 toggle() 999 } else if (market.dex.viewOnly) { 1000 page.unregisteredDex.textContent = market.dex.host 1001 showSection(page.notRegistered) 1002 } else if (market.dex.auth.targetTier > 0 && market.dex.auth.rep.penalties > market.dex.auth.penaltyComps) { 1003 page.acctPenalties.textContent = `${market.dex.auth.rep.penalties}` 1004 page.acctPenaltyComps.textContent = `${market.dex.auth.penaltyComps}` 1005 page.compsDexSettingsLink.href = `/dexsettings/${market.dex.host}` 1006 showSection(page.penaltyCompsRequired) 1007 } else if (this.hasPendingBonds()) { 1008 showSection(page.registrationStatus) 1009 } else if (market.dex.auth.targetTier > 0) { 1010 showSection(page.bondCreationPending) 1011 } else { 1012 page.acctTier.textContent = `${market.dex.auth.effectiveTier}` 1013 page.dexSettingsLink.href = `/dexsettings/${market.dex.host}` 1014 showSection(page.bondRequired) 1015 } 1016 } 1017 1018 setOrderBttnText () { 1019 if (this.isSell()) { 1020 this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_SELL, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) 1021 } else this.page.submitBttn.textContent = intl.prep(intl.ID_SET_BUTTON_BUY, { asset: Doc.shortSymbol(this.market.baseCfg.unitInfo.conventional.unit) }) 1022 } 1023 1024 setOrderBttnEnabled (isEnabled: boolean, disabledTooltipMsg?: string) { 1025 const btn = this.page.submitBttn 1026 if (isEnabled) { 1027 btn.removeAttribute('disabled') 1028 btn.removeAttribute('title') 1029 } else { 1030 btn.setAttribute('disabled', 'true') 1031 if (disabledTooltipMsg) btn.setAttribute('title', disabledTooltipMsg) 1032 } 1033 } 1034 1035 updateOrderBttnState () { 1036 const { market: mkt, currentOrder: { qty: orderQty, rate: orderRate, isLimit, sell } } = this 1037 const baseWallet = app().assets[this.market.base.id].wallet 1038 const quoteWallet = app().assets[mkt.quote.id].wallet 1039 if (!baseWallet || !quoteWallet) return 1040 1041 if (orderQty <= 0 || orderQty < mkt.cfg.lotsize) { 1042 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) 1043 return 1044 } 1045 1046 // Market orders 1047 if (!isLimit) { 1048 if (sell) { 1049 this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) 1050 } else { 1051 this.setOrderBttnEnabled(orderQty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) 1052 } 1053 return 1054 } 1055 1056 if (!orderRate) { 1057 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR)) 1058 return 1059 } 1060 1061 // Limit sell 1062 if (sell) { 1063 if (baseWallet.balance.available < mkt.cfg.lotsize) { 1064 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) 1065 return 1066 } 1067 if (mkt.maxSell) { 1068 this.setOrderBttnEnabled(orderQty <= mkt.maxSell.swap.value, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) 1069 } 1070 return 1071 } 1072 1073 // Limit buy 1074 const rate = this.adjustedRate() 1075 const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor) 1076 if (quoteWallet.balance.available < aLot) { 1077 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) 1078 return 1079 } 1080 if (mkt.maxBuys[rate]) { 1081 const enable = orderQty <= mkt.maxBuys[rate].swap.lots * mkt.cfg.lotsize 1082 this.setOrderBttnEnabled(enable, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) 1083 } 1084 } 1085 1086 setCandleDurBttns () { 1087 const { page, market } = this 1088 Doc.empty(page.durBttnBox) 1089 for (const dur of market.dex.candleDurs) { 1090 const bttn = page.durBttnTemplate.cloneNode(true) 1091 bttn.textContent = dur 1092 Doc.bind(bttn, 'click', () => this.candleDurationSelected(dur)) 1093 page.durBttnBox.appendChild(bttn) 1094 } 1095 1096 // load candlesticks here since we are resetting page.durBttnBox above. 1097 this.loadCandles() 1098 } 1099 1100 /* setMarket sets the currently displayed market. */ 1101 async setMarket (host: string, baseID: number, quoteID: number) { 1102 const dex = app().user.exchanges[host] 1103 const page = this.page 1104 1105 window.cexBook = async () => { 1106 const res = await postJSON('/api/cexbook', { host, baseID, quoteID }) 1107 console.log(res.book) 1108 } 1109 1110 // reset form inputs 1111 page.lotField.value = '' 1112 page.qtyField.value = '' 1113 page.rateField.value = '' 1114 1115 // clear depth chart and orderbook. 1116 this.depthChart.clear() 1117 Doc.empty(this.page.buyRows) 1118 Doc.empty(this.page.sellRows) 1119 1120 // Clear recent matches for the previous market. This will be set when we 1121 // receive the order book subscription response. 1122 this.recentMatches = [] 1123 Doc.empty(page.recentMatchesLiveList) 1124 1125 // Hide the balance widget 1126 this.balanceWgt.setBalanceVisibility(false) 1127 1128 Doc.hide(page.notRegistered, page.bondRequired, page.noWallet, page.penaltyCompsRequired) 1129 1130 // If we have not yet connected, there is no dex.assets or any other 1131 // exchange data, so just put up a message and wait for the connection to be 1132 // established, at which time handleConnNote will refresh and reload. 1133 if (!dex || !dex.markets || dex.connectionStatus !== ConnectionStatus.Connected) { 1134 let errMsg = intl.prep(intl.ID_CONNECTION_FAILED) 1135 if (dex.disabled) errMsg = intl.prep(intl.ID_DEX_DISABLED_MSG) 1136 page.chartErrMsg.textContent = errMsg 1137 Doc.show(page.chartErrMsg) 1138 return 1139 } 1140 1141 for (const s of this.stats) Doc.show(s.row) 1142 1143 const baseCfg = dex.assets[baseID] 1144 const quoteCfg = dex.assets[quoteID] 1145 1146 const [bui, qui] = [app().unitInfo(baseID, dex), app().unitInfo(quoteID, dex)] 1147 1148 const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor 1149 Doc.hide(page.maxOrd, page.chartErrMsg) 1150 if (this.maxEstimateTimer) { 1151 window.clearTimeout(this.maxEstimateTimer) 1152 this.maxEstimateTimer = null 1153 } 1154 const mktId = marketID(baseCfg.symbol, quoteCfg.symbol) 1155 const baseAsset = app().assets[baseID] 1156 const quoteAsset = app().assets[quoteID] 1157 1158 const mkt = { 1159 dex: dex, 1160 sid: mktId, // A string market identifier used by the DEX. 1161 cfg: dex.markets[mktId], 1162 // app().assets is a map of core.SupportedAsset type, which can be found at 1163 // client/core/types.go. 1164 base: baseAsset, 1165 quote: quoteAsset, 1166 baseUnitInfo: bui, 1167 quoteUnitInfo: qui, 1168 maxSell: null, 1169 maxBuys: {}, 1170 maxSellRequested: false, 1171 candleCaches: {}, 1172 baseCfg, 1173 quoteCfg, 1174 rateConversionFactor, 1175 sellBalance: 0, 1176 buyBalance: 0, 1177 bookLoaded: false 1178 } 1179 1180 this.market = mkt 1181 this.mm.setMarket(host, baseID, quoteID) 1182 this.mmRunning = undefined 1183 page.lotSize.textContent = Doc.formatCoinValue(mkt.cfg.lotsize, mkt.baseUnitInfo) 1184 page.rateStep.textContent = Doc.formatCoinValue(mkt.cfg.ratestep / rateConversionFactor) 1185 1186 this.displayMessageIfMissingWallet() 1187 this.balanceWgt.setWallets(host, baseID, quoteID) 1188 this.setMarketDetails() 1189 this.setCurrMarketPrice() 1190 1191 // if (!dex.candleDurs || dex.candleDurs.length === 0) this.currentChart = depthChart 1192 1193 // depth chart 1194 ws.request('loadmarket', makeMarket(host, baseID, quoteID)) 1195 1196 State.storeLocal(State.lastMarketLK, { 1197 host: host, 1198 base: baseID, 1199 quote: quoteID 1200 }) 1201 app().updateMarketElements(this.main, baseID, quoteID, dex) 1202 this.marketList.select(host, baseID, quoteID) 1203 this.setLoaderMsgVisibility() 1204 this.setTokenApprovalVisibility() 1205 this.setRegistrationStatusVisibility() 1206 this.resolveOrderFormVisibility() 1207 this.setOrderBttnText() 1208 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_RATE_ERROR)) 1209 this.setCandleDurBttns() 1210 this.previewQuoteAmt(false) 1211 this.updateTitle() 1212 this.reputationMeter.setHost(dex.host) 1213 this.updateReputation() 1214 this.loadUserOrders() 1215 } 1216 1217 /* 1218 displayMessageForMissingWallet displays a custom message on the market's 1219 view if one or more of the selected market's wallet is missing. 1220 */ 1221 displayMessageIfMissingWallet () { 1222 const page = this.page 1223 const mkt = this.market 1224 const baseSym = mkt.baseCfg.symbol.toLocaleUpperCase() 1225 const quoteSym = mkt.quoteCfg.symbol.toLocaleUpperCase() 1226 let noWalletMsg = '' 1227 Doc.hide(page.noWallet) 1228 if (!mkt.base?.wallet && !mkt.quote?.wallet) noWalletMsg = intl.prep(intl.ID_NO_WALLET_MSG, { asset1: baseSym, asset2: quoteSym }) 1229 else if (!mkt.base?.wallet) noWalletMsg = intl.prep(intl.ID_CREATE_ASSET_WALLET_MSG, { asset: baseSym }) 1230 else if (!mkt.quote?.wallet) noWalletMsg = intl.prep(intl.ID_CREATE_ASSET_WALLET_MSG, { asset: quoteSym }) 1231 else return 1232 1233 page.noWallet.textContent = noWalletMsg 1234 Doc.show(page.noWallet) 1235 } 1236 1237 /* 1238 * reportDepthClick is a callback used by the DepthChart when the user clicks 1239 * on the chart area. The rate field is set to the x-value of the click. 1240 */ 1241 reportDepthClick (r: number) { 1242 this.page.rateField.value = String(r) 1243 this.rateFieldChanged() 1244 } 1245 1246 /* 1247 * reportDepthVolume accepts a volume report from the DepthChart and sets the 1248 * values in the chart legend. 1249 */ 1250 reportDepthVolume (r: VolumeReport) { 1251 const page = this.page 1252 const { baseUnitInfo: b, quoteUnitInfo: q } = this.market 1253 // DepthChart reports volumes in conventional units. We'll still use 1254 // formatCoinValue for formatting though. 1255 page.sellBookedBase.textContent = Doc.formatCoinValue(r.sellBase * b.conventional.conversionFactor, b) 1256 page.sellBookedQuote.textContent = Doc.formatCoinValue(r.sellQuote * q.conventional.conversionFactor, q) 1257 page.buyBookedBase.textContent = Doc.formatCoinValue(r.buyBase * b.conventional.conversionFactor, b) 1258 page.buyBookedQuote.textContent = Doc.formatCoinValue(r.buyQuote * q.conventional.conversionFactor, q) 1259 } 1260 1261 /* 1262 * reportDepthMouse accepts information about the mouse position on the chart 1263 * area. 1264 */ 1265 reportDepthMouse (r: MouseReport) { 1266 while (this.hovers.length) (this.hovers.shift() as HTMLElement).classList.remove('hover') 1267 const page = this.page 1268 if (!r) { 1269 Doc.hide(page.depthLegend) 1270 return 1271 } 1272 Doc.show(page.depthLegend) 1273 1274 // If the user is hovered to within a small percent (based on chart width) 1275 // of a user order, highlight that order's row. 1276 for (const { div, ord } of Object.values(this.metaOrders)) { 1277 if (ord.status !== OrderUtil.StatusBooked) continue 1278 if (r.hoverMarkers.indexOf(ord.rate) > -1) { 1279 div.classList.add('hover') 1280 this.hovers.push(div) 1281 } 1282 } 1283 1284 page.hoverPrice.textContent = Doc.formatCoinValue(r.rate) 1285 page.hoverVolume.textContent = Doc.formatCoinValue(r.depth) 1286 page.hoverVolume.style.color = r.dotColor 1287 } 1288 1289 /* 1290 * reportDepthZoom accepts information about the current depth chart zoom 1291 * level. This information is saved to disk so that the zoom level can be 1292 * maintained across reloads. 1293 */ 1294 reportDepthZoom (zoom: number) { 1295 State.storeLocal(State.depthZoomLK, zoom) 1296 } 1297 1298 reportMouseCandle (candle: Candle | null) { 1299 const page = this.page 1300 if (!candle) { 1301 Doc.hide(page.candlesLegend) 1302 return 1303 } 1304 Doc.show(page.candlesLegend) 1305 page.candleStart.textContent = Doc.formatCoinValue(candle.startRate / this.market.rateConversionFactor) 1306 page.candleEnd.textContent = Doc.formatCoinValue(candle.endRate / this.market.rateConversionFactor) 1307 page.candleHigh.textContent = Doc.formatCoinValue(candle.highRate / this.market.rateConversionFactor) 1308 page.candleLow.textContent = Doc.formatCoinValue(candle.lowRate / this.market.rateConversionFactor) 1309 page.candleVol.textContent = Doc.formatCoinValue(candle.matchVolume, this.market.baseUnitInfo) 1310 } 1311 1312 /* 1313 * parseOrder pulls the order information from the form fields. Data is not 1314 * validated in any way. 1315 */ 1316 parseOrder (): TradeForm { 1317 const page = this.page 1318 let qtyField = page.qtyField 1319 const limit = this.isLimit() 1320 const sell = this.isSell() 1321 const market = this.market 1322 let qtyConv = market.baseUnitInfo.conventional.conversionFactor 1323 if (!limit && !sell) { 1324 qtyField = page.mktBuyField 1325 qtyConv = market.quoteUnitInfo.conventional.conversionFactor 1326 } 1327 return { 1328 host: market.dex.host, 1329 isLimit: limit, 1330 sell: sell, 1331 base: market.base.id, 1332 quote: market.quote.id, 1333 qty: convertToAtoms(qtyField.value || '', qtyConv), 1334 rate: convertToAtoms(page.rateField.value || '', market.rateConversionFactor), // message-rate 1335 tifnow: page.tifNow.checked || false, 1336 options: {} 1337 } 1338 } 1339 1340 /** 1341 * previewQuoteAmt shows quote amount when rate or quantity input are changed 1342 */ 1343 previewQuoteAmt (show: boolean) { 1344 const page = this.page 1345 if (!this.market.base || !this.market.quote) return // Not a supported asset 1346 const order = this.currentOrder = this.parseOrder() 1347 const adjusted = this.adjustedRate() 1348 page.orderErr.textContent = '' 1349 if (adjusted) { 1350 if (order.sell) this.preSell() 1351 else this.preBuy() 1352 } 1353 this.depthLines.input = [] 1354 if (adjusted && this.isLimit()) { 1355 this.depthLines.input = [{ 1356 rate: order.rate / this.market.rateConversionFactor, 1357 color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine 1358 }] 1359 } 1360 this.drawChartLines() 1361 if (!show || !adjusted || !order.qty) { 1362 page.orderPreview.textContent = '' 1363 this.drawChartLines() 1364 return 1365 } 1366 const { unitInfo: { conventional: { unit } } } = app().assets[order.quote] 1367 const quoteQty = order.qty * order.rate / OrderUtil.RateEncodingFactor 1368 const total = Doc.formatCoinValue(quoteQty, this.market.quoteUnitInfo) 1369 1370 page.orderPreview.textContent = intl.prep(intl.ID_ORDER_PREVIEW, { total, asset: unit }) 1371 if (this.isSell()) this.preSell() 1372 else this.preBuy() 1373 } 1374 1375 /** 1376 * preSell populates the max order message for the largest available sell. 1377 */ 1378 preSell () { 1379 const mkt = this.market 1380 const baseWallet = app().assets[mkt.base.id].wallet 1381 if (baseWallet.balance.available < mkt.cfg.lotsize) { 1382 this.setMaxOrder(null) 1383 this.updateOrderBttnState() 1384 return 1385 } 1386 if (mkt.maxSell) { 1387 this.setMaxOrder(mkt.maxSell.swap) 1388 this.updateOrderBttnState() 1389 return 1390 } 1391 1392 if (mkt.maxSellRequested) return 1393 mkt.maxSellRequested = true 1394 // We only fetch pre-sell once per balance update, so don't delay. 1395 this.scheduleMaxEstimate('/api/maxsell', {}, 0, (res: MaxSell) => { 1396 mkt.maxSellRequested = false 1397 mkt.maxSell = res.maxSell 1398 mkt.sellBalance = baseWallet.balance.available 1399 this.setMaxOrder(res.maxSell.swap) 1400 this.updateOrderBttnState() 1401 }) 1402 } 1403 1404 /** 1405 * preBuy populates the max order message for the largest available buy. 1406 */ 1407 preBuy () { 1408 const mkt = this.market 1409 const rate = this.adjustedRate() 1410 const quoteWallet = app().assets[mkt.quote.id].wallet 1411 if (!quoteWallet) return 1412 const aLot = mkt.cfg.lotsize * (rate / OrderUtil.RateEncodingFactor) 1413 if (quoteWallet.balance.available < aLot) { 1414 this.setMaxOrder(null) 1415 this.updateOrderBttnState() 1416 return 1417 } 1418 if (mkt.maxBuys[rate]) { 1419 this.setMaxOrder(mkt.maxBuys[rate].swap) 1420 this.updateOrderBttnState() 1421 return 1422 } 1423 // 0 delay for first fetch after balance update or market change, otherwise 1424 // meter these at 1 / sec. 1425 const delay = Object.keys(mkt.maxBuys).length ? 350 : 0 1426 this.scheduleMaxEstimate('/api/maxbuy', { rate }, delay, (res: MaxBuy) => { 1427 mkt.maxBuys[rate] = res.maxBuy 1428 mkt.buyBalance = app().assets[mkt.quote.id].wallet.balance.available 1429 this.setMaxOrder(res.maxBuy.swap) 1430 this.updateOrderBttnState() 1431 }) 1432 } 1433 1434 /** 1435 * scheduleMaxEstimate shows the loading icon and schedules a call to an order 1436 * estimate api endpoint. If another call to scheduleMaxEstimate is made before 1437 * this one is fired (after delay), this call will be canceled. 1438 */ 1439 scheduleMaxEstimate (path: string, args: any, delay: number, success: (res: any) => void) { 1440 const page = this.page 1441 if (!this.maxLoaded) this.maxLoaded = app().loading(page.maxOrd) 1442 const [bid, qid] = [this.market.base.id, this.market.quote.id] 1443 const [bWallet, qWallet] = [app().assets[bid].wallet, app().assets[qid].wallet] 1444 if (!bWallet || !bWallet.running || !qWallet || !qWallet.running) return 1445 if (this.maxEstimateTimer) window.clearTimeout(this.maxEstimateTimer) 1446 1447 Doc.show(page.maxOrd, page.maxLotBox) 1448 Doc.hide(page.maxAboveZero, page.maxZeroNoFees, page.maxZeroNoBal) 1449 page.maxFromLots.textContent = intl.prep(intl.ID_CALCULATING) 1450 page.maxFromLotsLbl.textContent = '' 1451 this.maxOrderUpdateCounter++ 1452 const counter = this.maxOrderUpdateCounter 1453 this.maxEstimateTimer = window.setTimeout(async () => { 1454 this.maxEstimateTimer = null 1455 if (counter !== this.maxOrderUpdateCounter) return 1456 const res = await postJSON(path, { 1457 host: this.market.dex.host, 1458 base: bid, 1459 quote: qid, 1460 ...args 1461 }) 1462 if (counter !== this.maxOrderUpdateCounter) return 1463 if (!app().checkResponse(res)) { 1464 console.warn('max order estimate not available:', res) 1465 page.maxFromLots.textContent = intl.prep(intl.ID_ESTIMATE_UNAVAILABLE) 1466 if (this.maxLoaded) { 1467 this.maxLoaded() 1468 this.maxLoaded = null 1469 } 1470 return 1471 } 1472 success(res) 1473 }, delay) 1474 } 1475 1476 /* setMaxOrder sets the max order text. */ 1477 setMaxOrder (maxOrder: SwapEstimate | null) { 1478 const page = this.page 1479 if (this.maxLoaded) { 1480 this.maxLoaded() 1481 this.maxLoaded = null 1482 } 1483 Doc.show(page.maxOrd, page.maxLotBox) 1484 const sell = this.isSell() 1485 1486 let lots = 0 1487 if (maxOrder) lots = maxOrder.lots 1488 1489 page.maxFromLots.textContent = lots.toString() 1490 // XXX add plural into format details, so we don't need this 1491 page.maxFromLotsLbl.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS) 1492 if (!maxOrder) return 1493 1494 const fromAsset = sell ? this.market.base : this.market.quote 1495 1496 if (lots === 0) { 1497 // If we have a maxOrder, see if we can guess why we have no lots. 1498 let lotSize = this.market.cfg.lotsize 1499 if (!sell) { 1500 const conversionRate = this.anyRate()[1] 1501 if (conversionRate === 0) return 1502 lotSize = lotSize * conversionRate 1503 } 1504 const haveQty = fromAsset.wallet.balance.available / lotSize > 0 1505 if (haveQty) { 1506 if (fromAsset.token) { 1507 const { wallet: { balance: { available: feeAvail } }, unitInfo } = app().assets[fromAsset.token.parentID] 1508 if (feeAvail < maxOrder.feeReservesPerLot) { 1509 Doc.show(page.maxZeroNoFees) 1510 page.maxZeroNoFeesTicker.textContent = unitInfo.conventional.unit 1511 page.maxZeroMinFees.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo) 1512 } 1513 // It looks like we should be able to afford it, but maybe some fees we're not seeing. 1514 // Show nothing. 1515 return 1516 } 1517 // Not a token. Maybe we have enough for the swap but not for fees. 1518 const fundedLots = fromAsset.wallet.balance.available / (lotSize + maxOrder.feeReservesPerLot) 1519 if (fundedLots > 0) return // Not sure why. Could be split txs or utxos. Just show nothing. 1520 } 1521 Doc.show(page.maxZeroNoBal) 1522 page.maxZeroNoBalTicker.textContent = fromAsset.unitInfo.conventional.unit 1523 return 1524 } 1525 Doc.show(page.maxAboveZero) 1526 1527 page.maxFromAmt.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo) 1528 page.maxFromTicker.textContent = fromAsset.unitInfo.conventional.unit 1529 // Could subtract the maxOrder.redemptionFees here. 1530 // The qty conversion doesn't fit well with the new design. 1531 // TODO: Make this work somehow? 1532 // const toConversion = sell ? this.adjustedRate() / OrderUtil.RateEncodingFactor : OrderUtil.RateEncodingFactor / this.adjustedRate() 1533 // page.maxToAmt.textContent = Doc.formatCoinValue((maxOrder.value || 0) * toConversion, toAsset.unitInfo) 1534 // page.maxToTicker.textContent = toAsset.symbol.toUpperCase() 1535 } 1536 1537 /* 1538 * validateOrder performs some basic order sanity checks, returning boolean 1539 * true if the order appears valid. 1540 */ 1541 validateOrder (order: TradeForm) { 1542 const { page, market: { cfg: { minimumRate }, rateConversionFactor } } = this 1543 if (order.isLimit) { 1544 if (!order.rate) { 1545 Doc.show(page.orderErr) 1546 page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_RATE) 1547 return false 1548 } 1549 if (order.rate < minimumRate) { 1550 Doc.show(page.orderErr) 1551 const [r, minRate] = [order.rate / rateConversionFactor, minimumRate / rateConversionFactor] 1552 page.orderErr.textContent = `rate is lower than the market's minimum rate. ${r} < ${minRate}` 1553 return false 1554 } 1555 } 1556 if (!order.qty) { 1557 Doc.show(page.orderErr) 1558 page.orderErr.textContent = intl.prep(intl.ID_NO_ZERO_QUANTITY) 1559 return false 1560 } 1561 return true 1562 } 1563 1564 /* handleBook accepts the data sent in the 'book' notification. */ 1565 handleBook (data: MarketOrderBook) { 1566 const { cfg, baseUnitInfo, quoteUnitInfo, baseCfg, quoteCfg } = this.market 1567 this.book = new OrderBook(data, baseCfg.symbol, quoteCfg.symbol) 1568 this.loadTable() 1569 for (const order of (data.book.epoch || [])) { 1570 if (order.rate > 0) this.book.add(order) 1571 this.addTableOrder(order) 1572 } 1573 if (!this.book) { 1574 this.depthChart.clear() 1575 Doc.empty(this.page.buyRows) 1576 Doc.empty(this.page.sellRows) 1577 return 1578 } 1579 Doc.show(this.page.epochLine) 1580 if (this.loadingAnimations.depth) this.loadingAnimations.depth.stop() 1581 this.depthChart.canvas.classList.remove('invisible') 1582 this.depthChart.set(this.book, cfg.lotsize, cfg.ratestep, baseUnitInfo, quoteUnitInfo) 1583 this.recentMatches = data.book.recentMatches ?? [] 1584 this.refreshRecentMatchesTable() 1585 } 1586 1587 /* 1588 * midGapConventional is the same as midGap, but returns the mid-gap rate as 1589 * the conventional ratio. This is used to convert from a conventional 1590 * quantity from base to quote or vice-versa, or for display purposes. 1591 */ 1592 midGapConventional () { 1593 const gap = this.midGap() 1594 if (!gap) return gap 1595 const { baseUnitInfo: b, quoteUnitInfo: q } = this.market 1596 return gap * b.conventional.conversionFactor / q.conventional.conversionFactor 1597 } 1598 1599 /* 1600 * midGap returns the value in the middle of the best buy and best sell. If 1601 * either one of the buy or sell sides are empty, midGap returns the best rate 1602 * from the other side. If both sides are empty, midGap returns the value 1603 * null. The rate returned is the atomic ratio, used for conversion. For a 1604 * conventional rate for display or to convert conventional units, use 1605 * midGapConventional 1606 */ 1607 midGap () { 1608 const book = this.book 1609 if (!book) return 1610 if (book.buys && book.buys.length) { 1611 if (book.sells && book.sells.length) { 1612 return (book.buys[0].msgRate + book.sells[0].msgRate) / 2 / OrderUtil.RateEncodingFactor 1613 } 1614 return book.buys[0].msgRate / OrderUtil.RateEncodingFactor 1615 } 1616 if (book.sells && book.sells.length) { 1617 return book.sells[0].msgRate / OrderUtil.RateEncodingFactor 1618 } 1619 return null 1620 } 1621 1622 /* 1623 * setMarketBuyOrderEstimate sets the "min. buy" display for the current 1624 * market. 1625 */ 1626 setMarketBuyOrderEstimate () { 1627 const market = this.market 1628 const lotSize = market.cfg.lotsize 1629 const xc = app().user.exchanges[market.dex.host] 1630 const buffer = xc.markets[market.sid].buybuffer 1631 const gap = this.midGapConventional() 1632 if (gap) { 1633 this.page.minMktBuy.textContent = Doc.formatCoinValue(lotSize * buffer * gap, market.baseUnitInfo) 1634 } 1635 } 1636 1637 maxUserOrderCount (): number { 1638 const { dex: { host }, cfg: { name: mktID } } = this.market 1639 return Math.max(maxUserOrdersShown, app().orders(host, mktID).length) 1640 } 1641 1642 async loadUserOrders () { 1643 const { base: b, quote: q, dex: { host }, cfg: { name: mktID } } = this.market 1644 for (const oid in this.metaOrders) delete this.metaOrders[oid] 1645 if (!b || !q) return this.resolveUserOrders([]) // unsupported asset 1646 const activeOrders = app().orders(host, mktID) 1647 if (activeOrders.length >= maxUserOrdersShown) return this.resolveUserOrders(activeOrders) 1648 const filter: OrderFilter = { 1649 hosts: [host], 1650 market: { baseID: b.id, quoteID: q.id }, 1651 n: this.maxUserOrderCount() 1652 } 1653 const res = await postJSON('/api/orders', filter) 1654 const orders = res.orders || [] 1655 // Make sure all active orders are in there. The /orders API sorts by time, 1656 // so if there is are 10 cancelled/executed orders newer than an old active 1657 // order, the active order wouldn't be included in the result. 1658 for (const activeOrd of activeOrders) if (!orders.some((dbOrd: Order) => dbOrd.id === activeOrd.id)) orders.push(activeOrd) 1659 return this.resolveUserOrders(res.orders || []) 1660 } 1661 1662 /* refreshActiveOrders refreshes the user's active order list. */ 1663 refreshActiveOrders () { 1664 const orders = app().orders(this.market.dex.host, marketID(this.market.baseCfg.symbol, this.market.quoteCfg.symbol)) 1665 return this.resolveUserOrders(orders) 1666 } 1667 1668 resolveUserOrders (orders: Order[]) { 1669 const { page, metaOrders, market } = this 1670 const cfg = market.cfg 1671 1672 const orderIsActive = (ord: Order) => ord.status < OrderUtil.StatusExecuted || OrderUtil.hasActiveMatches(ord) 1673 1674 for (const ord of orders) metaOrders[ord.id] = { ord: ord } as MetaOrder 1675 let sortedOrders = Object.keys(metaOrders).map((oid: string) => metaOrders[oid]) 1676 sortedOrders.sort((a: MetaOrder, b: MetaOrder) => { 1677 const [aActive, bActive] = [orderIsActive(a.ord), orderIsActive(b.ord)] 1678 if (aActive && !bActive) return -1 1679 else if (!aActive && bActive) return 1 1680 return b.ord.submitTime - a.ord.submitTime 1681 }) 1682 const n = this.maxUserOrderCount() 1683 if (sortedOrders.length > n) { sortedOrders = sortedOrders.slice(0, n) } 1684 1685 for (const oid in metaOrders) delete metaOrders[oid] 1686 1687 Doc.empty(page.userOrders) 1688 Doc.setVis(sortedOrders?.length, page.userOrders) 1689 Doc.setVis(!sortedOrders?.length, page.userNoOrders) 1690 1691 let unreadyOrders = false 1692 for (const mord of sortedOrders) { 1693 const div = page.userOrderTmpl.cloneNode(true) as HTMLElement 1694 page.userOrders.appendChild(div) 1695 const tmpl = Doc.parseTemplate(div) 1696 const header = Doc.parseTemplate(tmpl.header) 1697 const details = Doc.parseTemplate(tmpl.details) 1698 1699 mord.div = div 1700 mord.header = header 1701 mord.details = details 1702 const ord = mord.ord 1703 1704 const orderID = ord.id 1705 const isActive = orderIsActive(ord) 1706 1707 // No need to track in-flight orders here. We've already added it to 1708 // display. 1709 if (orderID) metaOrders[orderID] = mord 1710 1711 if (!ord.readyToTick && OrderUtil.hasActiveMatches(ord)) { 1712 tmpl.header.classList.add('unready-user-order') 1713 unreadyOrders = true 1714 } 1715 header.sideLight.classList.add(ord.sell ? 'sell' : 'buy') 1716 if (!isActive) header.sideLight.classList.add('inactive') 1717 details.side.textContent = mord.header.side.textContent = OrderUtil.sellString(ord) 1718 details.side.classList.add(ord.sell ? 'sellcolor' : 'buycolor') 1719 header.side.classList.add(ord.sell ? 'sellcolor' : 'buycolor') 1720 details.qty.textContent = mord.header.qty.textContent = Doc.formatCoinValue(ord.qty, market.baseUnitInfo) 1721 let rateStr: string 1722 if (ord.type === OrderUtil.Market) rateStr = this.marketOrderRateString(ord, market) 1723 else rateStr = Doc.formatRateFullPrecision(ord.rate, market.baseUnitInfo, market.quoteUnitInfo, cfg.ratestep) 1724 details.rate.textContent = mord.header.rate.textContent = rateStr 1725 header.baseSymbol.textContent = market.baseUnitInfo.conventional.unit 1726 details.type.textContent = OrderUtil.orderTypeText(ord.type) 1727 this.updateMetaOrder(mord) 1728 1729 Doc.bind(div, 'mouseenter', () => { 1730 this.activeMarkerRate = ord.rate 1731 this.setDepthMarkers() 1732 }) 1733 1734 const showCancel = (e: Event) => { 1735 e.stopPropagation() 1736 this.showCancel(div, orderID) 1737 } 1738 1739 const showAccelerate = (e: Event) => { 1740 e.stopPropagation() 1741 this.showAccelerate(ord) 1742 } 1743 1744 if (!orderID) { 1745 Doc.hide(details.accelerateBttn) 1746 Doc.hide(details.cancelBttn) 1747 Doc.hide(details.link) 1748 } else { 1749 if (OrderUtil.isCancellable(ord)) { 1750 Doc.show(details.cancelBttn) 1751 bind(details.cancelBttn, 'click', (e: Event) => { showCancel(e) }) 1752 } 1753 1754 bind(details.accelerateBttn, 'click', (e: Event) => { showAccelerate(e) }) 1755 if (app().canAccelerateOrder(ord)) { 1756 Doc.show(details.accelerateBttn) 1757 } 1758 1759 details.link.href = `order/${orderID}` 1760 app().bindInternalNavigation(div) 1761 } 1762 let currentFloater: (PageElement | null) 1763 Doc.bind(tmpl.header, 'click', () => { 1764 if (Doc.isDisplayed(tmpl.details)) { 1765 Doc.hide(tmpl.details) 1766 header.expander.classList.add('ico-arrowdown') 1767 header.expander.classList.remove('ico-arrowup') 1768 return 1769 } 1770 Doc.show(tmpl.details) 1771 header.expander.classList.remove('ico-arrowdown') 1772 header.expander.classList.add('ico-arrowup') 1773 if (currentFloater) currentFloater.remove() 1774 }) 1775 /** 1776 * We'll show the button menu when they hover over the header. To avoid 1777 * pushing the layout around, we'll show the buttons as an absolutely 1778 * positioned copy of the button menu. 1779 */ 1780 Doc.bind(tmpl.header, 'mouseenter', () => { 1781 // Don't show the copy if the details are already displayed. 1782 if (Doc.isDisplayed(tmpl.details)) return 1783 if (currentFloater) currentFloater.remove() 1784 // Create and position the element based on the position of the header. 1785 const floater = document.createElement('div') 1786 currentFloater = floater 1787 document.body.appendChild(floater) 1788 floater.className = 'user-order-floaty-menu' 1789 const m = Doc.layoutMetrics(tmpl.header) 1790 const y = m.bodyTop + m.height 1791 floater.style.top = `${y - 1}px` // - 1 to hide border on header div 1792 floater.style.left = `${m.bodyLeft}px` 1793 // Get the updated version of the order 1794 const mord = this.metaOrders[orderID] 1795 const ord = mord.ord 1796 1797 const addButton = (baseBttn: PageElement, cb: ((e: Event) => void)) => { 1798 const icon = baseBttn.cloneNode(true) as PageElement 1799 floater.appendChild(icon) 1800 Doc.show(icon) 1801 Doc.bind(icon, 'click', (e: Event) => { cb(e) }) 1802 } 1803 1804 if (OrderUtil.isCancellable(ord)) addButton(details.cancelBttn, (e: Event) => { showCancel(e) }) 1805 if (app().canAccelerateOrder(ord)) addButton(details.accelerateBttn, (e: Event) => { showAccelerate(e) }) 1806 floater.appendChild(details.link.cloneNode(true)) 1807 1808 const ogScrollY = page.orderScroller.scrollTop 1809 // Set up the hover interactions. 1810 const moved = (e: MouseEvent) => { 1811 // If the user scrolled, reposition the float menu. This keeps the 1812 // menu from following us around, which can prevent removal below. 1813 const yShift = page.orderScroller.scrollTop - ogScrollY 1814 floater.style.top = `${y + yShift}px` 1815 if (Doc.mouseInElement(e, floater) || Doc.mouseInElement(e, div)) return 1816 floater.remove() 1817 currentFloater = null 1818 document.removeEventListener('mousemove', moved) 1819 page.orderScroller.removeEventListener('scroll', moved) 1820 } 1821 document.addEventListener('mousemove', moved) 1822 page.orderScroller.addEventListener('scroll', moved) 1823 }) 1824 app().bindTooltips(div) 1825 } 1826 Doc.setVis(unreadyOrders, page.unreadyOrdersMsg) 1827 this.setDepthMarkers() 1828 } 1829 1830 /* 1831 marketOrderRateString uses the market config rate step to format the average 1832 market order rate. 1833 */ 1834 marketOrderRateString (ord: Order, mkt: CurrentMarket) :string { 1835 if (!ord.matches?.length) return intl.prep(intl.ID_MARKET_ORDER) 1836 let rateStr = Doc.formatRateFullPrecision(OrderUtil.averageRate(ord), mkt.baseUnitInfo, mkt.quoteUnitInfo, mkt.cfg.ratestep) 1837 if (ord.matches.length > 1) rateStr = '~ ' + rateStr // ~ only makes sense if the order has more than one match 1838 return rateStr 1839 } 1840 1841 /* 1842 * updateMetaOrder sets the td contents of the user's order table row. 1843 */ 1844 updateMetaOrder (mord: MetaOrder) { 1845 const { header, details, ord } = mord 1846 if (ord.status <= OrderUtil.StatusBooked || OrderUtil.hasActiveMatches(ord)) header.activeLight.classList.add('active') 1847 else header.activeLight.classList.remove('active') 1848 details.status.textContent = header.status.textContent = OrderUtil.statusString(ord) 1849 details.age.textContent = Doc.timeSince(ord.submitTime) 1850 details.filled.textContent = `${(OrderUtil.filled(ord) / ord.qty * 100).toFixed(1)}%` 1851 details.settled.textContent = `${(OrderUtil.settled(ord) / ord.qty * 100).toFixed(1)}%` 1852 } 1853 1854 /* setMarkers sets the depth chart markers for booked orders. */ 1855 setDepthMarkers () { 1856 const markers: Record<string, DepthMarker[]> = { 1857 buys: [], 1858 sells: [] 1859 } 1860 const rateFactor = this.market.rateConversionFactor 1861 for (const { ord } of Object.values(this.metaOrders)) { 1862 if (ord.rate && ord.status === OrderUtil.StatusBooked) { 1863 if (ord.sell) { 1864 markers.sells.push({ 1865 rate: ord.rate / rateFactor, 1866 active: ord.rate === this.activeMarkerRate 1867 }) 1868 } else { 1869 markers.buys.push({ 1870 rate: ord.rate / rateFactor, 1871 active: ord.rate === this.activeMarkerRate 1872 }) 1873 } 1874 } 1875 } 1876 this.depthChart.setMarkers(markers) 1877 if (this.book) this.depthChart.draw() 1878 } 1879 1880 /* updateTitle update the browser title based on the midgap value and the 1881 * selected assets. 1882 */ 1883 updateTitle () { 1884 // gets first price value from buy or from sell, so we can show it on 1885 // title. 1886 const midGapValue = this.midGapConventional() 1887 const { baseUnitInfo: { conventional: { unit: bUnit } }, quoteUnitInfo: { conventional: { unit: qUnit } } } = this.market 1888 if (!midGapValue) document.title = `${bUnit}${qUnit} | ${this.ogTitle}` 1889 else document.title = `${Doc.formatCoinValue(midGapValue)} | ${bUnit}${qUnit} | ${this.ogTitle}` // more than 6 numbers it gets too big for the title. 1890 } 1891 1892 /* handleBookRoute is the handler for the 'book' notification, which is sent 1893 * in response to a new market subscription. The data received will contain 1894 * the entire order book. 1895 */ 1896 handleBookRoute (note: BookUpdate) { 1897 app().log('book', 'handleBookRoute:', note) 1898 const mktBook = note.payload 1899 const { baseCfg: b, quoteCfg: q, dex: { host } } = this.market 1900 if (mktBook.base !== b.id || mktBook.quote !== q.id || note.host !== host) return // user already changed markets 1901 this.handleBook(mktBook) 1902 this.market.bookLoaded = true 1903 this.updateTitle() 1904 this.setMarketBuyOrderEstimate() 1905 } 1906 1907 /* handleBookOrderRoute is the handler for 'book_order' notifications. */ 1908 handleBookOrderRoute (data: BookUpdate) { 1909 app().log('book', 'handleBookOrderRoute:', data) 1910 if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return 1911 const order = data.payload as MiniOrder 1912 if (order.rate > 0) this.book.add(order) 1913 this.addTableOrder(order) 1914 this.updateTitle() 1915 this.depthChart.draw() 1916 } 1917 1918 /* handleUnbookOrderRoute is the handler for 'unbook_order' notifications. */ 1919 handleUnbookOrderRoute (data: BookUpdate) { 1920 app().log('book', 'handleUnbookOrderRoute:', data) 1921 if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return 1922 const order = data.payload 1923 this.book.remove(order.token) 1924 this.removeTableOrder(order) 1925 this.updateTitle() 1926 this.depthChart.draw() 1927 } 1928 1929 /* 1930 * handleUpdateRemainingRoute is the handler for 'update_remaining' 1931 * notifications. 1932 */ 1933 handleUpdateRemainingRoute (data: BookUpdate) { 1934 app().log('book', 'handleUpdateRemainingRoute:', data) 1935 if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return 1936 const update = data.payload 1937 this.book.updateRemaining(update.token, update.qty, update.qtyAtomic) 1938 this.updateTableOrder(update) 1939 this.depthChart.draw() 1940 } 1941 1942 /* handleEpochOrderRoute is the handler for 'epoch_order' notifications. */ 1943 handleEpochOrderRoute (data: BookUpdate) { 1944 app().log('book', 'handleEpochOrderRoute:', data) 1945 if (data.host !== this.market.dex.host || data.marketID !== this.market.sid) return 1946 const order = data.payload 1947 if (order.msgRate > 0) this.book.add(order) // No cancels or market orders 1948 if (order.qtyAtomic > 0) this.addTableOrder(order) // No cancel orders 1949 this.depthChart.draw() 1950 } 1951 1952 /* handleCandlesRoute is the handler for 'candles' notifications. */ 1953 handleCandlesRoute (data: BookUpdate) { 1954 if (this.candlesLoading) { 1955 clearTimeout(this.candlesLoading.timer) 1956 this.candlesLoading.loaded() 1957 this.candlesLoading = null 1958 } 1959 if (data.host !== this.market.dex.host || data.marketID !== this.market.cfg.name) return 1960 const dur = data.payload.dur 1961 this.market.candleCaches[dur] = data.payload 1962 this.setHighLow() 1963 if (this.candleDur !== dur) return 1964 if (this.loadingAnimations.candles) this.loadingAnimations.candles.stop() 1965 this.candleChart.canvas.classList.remove('invisible') 1966 this.candleChart.setCandles(data.payload, this.market.cfg, this.market.baseUnitInfo, this.market.quoteUnitInfo) 1967 } 1968 1969 handleEpochMatchSummary (data: BookUpdate) { 1970 this.addRecentMatches(data.payload.matchSummaries) 1971 this.refreshRecentMatchesTable() 1972 } 1973 1974 /* handleCandleUpdateRoute is the handler for 'candle_update' notifications. */ 1975 handleCandleUpdateRoute (data: BookUpdate) { 1976 if (data.host !== this.market.dex.host) return 1977 const { dur, candle } = data.payload 1978 const cache = this.market.candleCaches[dur] 1979 if (!cache) return // must not have seen the 'candles' notification yet? 1980 const candles = cache.candles 1981 if (candles.length === 0) candles.push(candle) 1982 else { 1983 const last = candles[candles.length - 1] 1984 if (last.startStamp === candle.startStamp) candles[candles.length - 1] = candle 1985 else candles.push(candle) 1986 } 1987 if (this.candleDur !== dur) return 1988 this.candleChart.draw() 1989 } 1990 1991 /* 1992 * showToggleWalletStatus displays the toggleWalletStatusConfirm form to 1993 * enable a wallet. 1994 */ 1995 showToggleWalletStatus (asset: SupportedAsset) { 1996 const page = this.page 1997 this.openAsset = asset 1998 Doc.hide(page.toggleWalletStatusErr, page.walletStatusDisable, page.disableWalletMsg) 1999 Doc.show(page.walletStatusEnable, page.enableWalletMsg) 2000 this.forms.show(page.toggleWalletStatusConfirm) 2001 } 2002 2003 /* 2004 * toggleWalletStatus toggle wallets status to enabled. 2005 */ 2006 async toggleWalletStatus () { 2007 const page = this.page 2008 Doc.hide(page.toggleWalletStatusErr) 2009 2010 const url = '/api/togglewalletstatus' 2011 const req = { 2012 assetID: this.openAsset.id, 2013 disable: false 2014 } 2015 2016 const loaded = app().loading(page.toggleWalletStatusConfirm) 2017 const res = await postJSON(url, req) 2018 loaded() 2019 if (!app().checkResponse(res)) { 2020 page.toggleWalletStatusErr.textContent = res.msg 2021 Doc.show(page.toggleWalletStatusErr) 2022 return 2023 } 2024 2025 Doc.hide(this.page.forms) 2026 this.balanceWgt.updateAsset(this.openAsset.id) 2027 } 2028 2029 /* showVerify shows the form to accept the currently parsed order information 2030 * and confirm submission of the order to the dex. 2031 */ 2032 showVerify () { 2033 this.preorderCache = {} 2034 const page = this.page 2035 const order = this.currentOrder = this.parseOrder() 2036 const isSell = order.sell 2037 const baseAsset = app().assets[order.base] 2038 const quoteAsset = app().assets[order.quote] 2039 const toAsset = isSell ? quoteAsset : baseAsset 2040 const fromAsset = isSell ? baseAsset : quoteAsset 2041 2042 const setIcon = (icon: PageElement) => { 2043 switch (icon.dataset.icon) { 2044 case 'from': 2045 if (fromAsset.token) { 2046 const parentAsset = app().assets[fromAsset.token.parentID] 2047 icon.src = Doc.logoPath(parentAsset.symbol) 2048 } else { 2049 icon.src = Doc.logoPath(fromAsset.symbol) 2050 } 2051 break 2052 case 'to': 2053 if (toAsset.token) { 2054 const parentAsset = app().assets[toAsset.token.parentID] 2055 icon.src = Doc.logoPath(parentAsset.symbol) 2056 } else { 2057 icon.src = Doc.logoPath(toAsset.symbol) 2058 } 2059 } 2060 } 2061 2062 // Set the to and from icons in the fee details pane. 2063 for (const icon of Doc.applySelector(page.vDetailPane, '[data-icon]')) { 2064 setIcon(icon) 2065 } 2066 2067 // Set the to and from icons in the fee summary pane. 2068 for (const icon of Doc.applySelector(page.vFeeSummary, '[data-icon]')) { 2069 setIcon(icon) 2070 } 2071 2072 Doc.hide(page.vPreorderErr) 2073 Doc.show(page.vPreorder) 2074 2075 page.vBuySell.textContent = isSell ? intl.prep(intl.ID_SELLING) : intl.prep(intl.ID_BUYING) 2076 const buySellStr = isSell ? intl.prep(intl.ID_SELL) : intl.prep(intl.ID_BUY) 2077 page.vSideSubmit.textContent = buySellStr 2078 page.vOrderHost.textContent = order.host 2079 if (order.isLimit) { 2080 Doc.show(page.verifyLimit) 2081 Doc.hide(page.verifyMarket) 2082 const orderDesc = `Limit ${buySellStr} Order` 2083 page.vOrderType.textContent = order.tifnow ? orderDesc + ' (immediate)' : orderDesc 2084 page.vRate.textContent = Doc.formatCoinValue(order.rate / this.market.rateConversionFactor) 2085 page.vQty.textContent = Doc.formatCoinValue(order.qty, baseAsset.unitInfo) 2086 const total = order.rate / OrderUtil.RateEncodingFactor * order.qty 2087 page.vTotal.textContent = Doc.formatCoinValue(total, quoteAsset.unitInfo) 2088 // Format total fiat value. 2089 this.showFiatValue(quoteAsset.id, total, page.vFiatTotal) 2090 } else { 2091 Doc.hide(page.verifyLimit) 2092 Doc.show(page.verifyMarket) 2093 page.vOrderType.textContent = `Market ${buySellStr} Order` 2094 const ui = order.sell ? this.market.baseUnitInfo : this.market.quoteUnitInfo 2095 page.vmFromTotal.textContent = Doc.formatCoinValue(order.qty, ui) 2096 page.vmFromAsset.textContent = fromAsset.symbol.toUpperCase() 2097 // Format fromAsset fiat value. 2098 this.showFiatValue(fromAsset.id, order.qty, page.vmFromTotalFiat) 2099 const gap = this.midGap() 2100 if (gap) { 2101 Doc.show(page.vMarketEstimate) 2102 const received = order.sell ? order.qty * gap : order.qty / gap 2103 page.vmToTotal.textContent = Doc.formatCoinValue(received, toAsset.unitInfo) 2104 page.vmToAsset.textContent = toAsset.symbol.toUpperCase() 2105 // Format received value to fiat equivalent. 2106 this.showFiatValue(toAsset.id, received, page.vmTotalFiat) 2107 } else { 2108 Doc.hide(page.vMarketEstimate) 2109 } 2110 } 2111 // Visually differentiate between buy/sell orders. 2112 if (isSell) { 2113 page.vHeader.classList.add(sellBtnClass) 2114 page.vHeader.classList.remove(buyBtnClass) 2115 page.vSubmit.classList.add(sellBtnClass) 2116 page.vSubmit.classList.remove(buyBtnClass) 2117 } else { 2118 page.vHeader.classList.add(buyBtnClass) 2119 page.vHeader.classList.remove(sellBtnClass) 2120 page.vSubmit.classList.add(buyBtnClass) 2121 page.vSubmit.classList.remove(sellBtnClass) 2122 } 2123 this.showVerifyForm() 2124 2125 if (baseAsset.wallet.open && quoteAsset.wallet.open) this.preOrder(order) 2126 else { 2127 Doc.hide(page.vPreorder) 2128 this.unlockWalletsForEstimates() 2129 } 2130 } 2131 2132 // showFiatValue displays the fiat equivalent for an order quantity. 2133 showFiatValue (assetID: number, qty: number, display: PageElement) { 2134 if (display) { 2135 const rate = app().fiatRatesMap[assetID] 2136 display.textContent = Doc.formatFiatConversion(qty, rate, app().unitInfo(assetID)) 2137 if (rate) Doc.show(display.parentElement as Element) 2138 else Doc.hide(display.parentElement as Element) 2139 } 2140 } 2141 2142 /* showVerifyForm displays form to verify an order */ 2143 async showVerifyForm () { 2144 const page = this.page 2145 Doc.hide(page.vErr) 2146 this.forms.show(page.verifyForm) 2147 } 2148 2149 /* 2150 * unlockWalletsForEstimates unlocks any locked wallets with the provided 2151 * password. 2152 */ 2153 async unlockWalletsForEstimates () { 2154 const page = this.page 2155 const loaded = app().loading(page.verifyForm) 2156 await this.unlockMarketWallets() 2157 loaded() 2158 Doc.show(page.vPreorder) 2159 this.preOrder(this.parseOrder()) 2160 } 2161 2162 async unlockWallet (assetID: number) { 2163 const res = await postJSON('/api/openwallet', { assetID }) 2164 if (!app().checkResponse(res)) { 2165 throw Error('error unlocking wallet ' + res.msg) 2166 } 2167 this.balanceWgt.updateAsset(assetID) 2168 } 2169 2170 /* 2171 * unlockMarketWallets unlocks both the base and quote wallets for the current 2172 * market, if locked. 2173 */ 2174 async unlockMarketWallets () { 2175 const { base, quote } = this.market 2176 const assetIDs = [] 2177 if (!base.wallet.open) assetIDs.push(base.id) 2178 if (!quote.wallet.open) assetIDs.push(quote.id) 2179 for (const assetID of assetIDs) { 2180 this.unlockWallet(assetID) 2181 } 2182 } 2183 2184 /* fetchPreorder fetches the pre-order estimates and options. */ 2185 async fetchPreorder (order: TradeForm) { 2186 const page = this.page 2187 const cacheKey = JSON.stringify(order.options) 2188 const cached = this.preorderCache[cacheKey] 2189 if (cached) return cached 2190 2191 Doc.hide(page.vPreorderErr) 2192 const loaded = app().loading(page.verifyForm) 2193 const res = await postJSON('/api/preorder', wireOrder(order)) 2194 loaded() 2195 if (!app().checkResponse(res)) return { err: res.msg } 2196 this.preorderCache[cacheKey] = res.estimate 2197 return res.estimate 2198 } 2199 2200 /* 2201 * setPreorderErr sets and displays the pre-order error message and hides the 2202 * pre-order details box. 2203 */ 2204 setPreorderErr (msg: string) { 2205 const page = this.page 2206 Doc.hide(page.vPreorder) 2207 Doc.show(page.vPreorderErr) 2208 page.vPreorderErrTip.dataset.tooltip = msg 2209 } 2210 2211 showPreOrderAdvancedOptions () { 2212 const page = this.page 2213 Doc.hide(page.showAdvancedOptions) 2214 Doc.show(page.hideAdvancedOptions, page.vOtherOrderOpts) 2215 } 2216 2217 hidePreOrderAdvancedOptions () { 2218 const page = this.page 2219 Doc.hide(page.hideAdvancedOptions, page.vOtherOrderOpts) 2220 Doc.show(page.showAdvancedOptions) 2221 } 2222 2223 reloadOrderOpts (order: TradeForm, swap: PreSwap, redeem: PreRedeem, changed: ()=>void) { 2224 const page = this.page 2225 Doc.empty(page.vDefaultOrderOpts, page.vOtherOrderOpts) 2226 const addOption = (opt: OrderOption, isSwap: boolean) => { 2227 const el = OrderUtil.optionElement(opt, order, changed, isSwap) 2228 if (opt.showByDefault) page.vDefaultOrderOpts.appendChild(el) 2229 else page.vOtherOrderOpts.appendChild(el) 2230 } 2231 for (const opt of swap.options || []) addOption(opt, true) 2232 for (const opt of redeem.options || []) addOption(opt, false) 2233 app().bindTooltips(page.vDefaultOrderOpts) 2234 app().bindTooltips(page.vOtherOrderOpts) 2235 } 2236 2237 /* preOrder loads the options and fetches pre-order estimates */ 2238 async preOrder (order: TradeForm) { 2239 const page = this.page 2240 2241 // Add swap options. 2242 const refreshPreorder = async () => { 2243 const res: APIResponse = await this.fetchPreorder(order) 2244 if (res.err) return this.setPreorderErr(res.err) 2245 const est = (res as any) as OrderEstimate 2246 Doc.hide(page.vPreorderErr) 2247 Doc.show(page.vPreorder) 2248 const { swap, redeem } = est 2249 swap.options = swap.options || [] 2250 redeem.options = redeem.options || [] 2251 this.setFeeEstimates(swap, redeem, order) 2252 2253 const changed = async () => { 2254 await refreshPreorder() 2255 Doc.animate(400, progress => { 2256 page.vFeeSummary.style.backgroundColor = `rgba(128, 128, 128, ${0.5 - 0.5 * progress})` 2257 }) 2258 } 2259 // bind show or hide advanced pre order options. 2260 Doc.bind(page.showAdvancedOptions, 'click', () => { this.showPreOrderAdvancedOptions() }) 2261 Doc.bind(page.hideAdvancedOptions, 'click', () => { this.hidePreOrderAdvancedOptions() }) 2262 this.reloadOrderOpts(order, swap, redeem, changed) 2263 } 2264 2265 refreshPreorder() 2266 } 2267 2268 /* setFeeEstimates sets all of the pre-order estimate fields */ 2269 setFeeEstimates (swap: PreSwap, redeem: PreRedeem, order: TradeForm) { 2270 const { page, market } = this 2271 if (!swap.estimate || !redeem.estimate) { 2272 Doc.hide(page.vPreorderEstimates) 2273 return // preOrder may return just options, no fee estimates 2274 } 2275 Doc.show(page.vPreorderEstimates) 2276 const { baseUnitInfo, quoteUnitInfo, rateConversionFactor } = market 2277 const fmtPct = (value: number) => { 2278 if (value < 0.05) return '< 0.1' 2279 return percentFormatter.format(value) 2280 } 2281 2282 // If the asset is a token, in order to calculate the fee as a percentage 2283 // of the total order, we try to use the fiat rates to find out the 2284 // exchange rate between the token and parent assets. 2285 // Initially these are set to 1, which we would use if the asset is not a 2286 // token and no conversion is needed. 2287 let baseExchangeRate = 1 2288 let quoteExchangeRate = 1 2289 let baseFeeAssetUI = baseUnitInfo 2290 let quoteFeeAssetUI = quoteUnitInfo 2291 2292 if (market.base.token) { 2293 const parent = app().assets[market.base.token.parentID] 2294 baseFeeAssetUI = parent.unitInfo 2295 const tokenFiatRate = app().fiatRatesMap[market.base.id] 2296 const parentFiatRate = app().fiatRatesMap[parent.id] 2297 if (tokenFiatRate && parentFiatRate) { 2298 const conventionalRate = parentFiatRate / tokenFiatRate 2299 baseExchangeRate = conventionalRate * baseUnitInfo.conventional.conversionFactor / parent.unitInfo.conventional.conversionFactor 2300 } else { 2301 baseExchangeRate = 0 2302 } 2303 } 2304 2305 if (market.quote.token) { 2306 const parent = app().assets[market.quote.token.parentID] 2307 quoteFeeAssetUI = parent.unitInfo 2308 const tokenFiatRate = app().fiatRatesMap[market.quote.id] 2309 const parentFiatRate = app().fiatRatesMap[parent.id] 2310 if (tokenFiatRate && parentFiatRate) { 2311 const conventionalRate = parentFiatRate / tokenFiatRate 2312 quoteExchangeRate = conventionalRate * quoteUnitInfo.conventional.conversionFactor / parent.unitInfo.conventional.conversionFactor 2313 } else { 2314 quoteExchangeRate = 0 2315 } 2316 } 2317 2318 let [toFeeAssetUI, fromFeeAssetUI] = [baseFeeAssetUI, quoteFeeAssetUI] 2319 let [toExchangeRate, fromExchangeRate] = [baseExchangeRate, quoteExchangeRate] 2320 if (this.currentOrder.sell) { 2321 [fromFeeAssetUI, toFeeAssetUI] = [toFeeAssetUI, fromFeeAssetUI]; 2322 [fromExchangeRate, toExchangeRate] = [toExchangeRate, fromExchangeRate] 2323 } 2324 2325 const swapped = swap.estimate.value || 0 2326 const swappedInParentUnits = fromExchangeRate > 0 ? swapped / fromExchangeRate : swapped 2327 2328 // Set swap fee estimates in the details pane. 2329 const bestSwapPct = swap.estimate.realisticBestCase / swappedInParentUnits * 100 2330 page.vSwapFeesLowPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(bestSwapPct)}%)` 2331 page.vSwapFeesLow.textContent = Doc.formatCoinValue(swap.estimate.realisticBestCase, fromFeeAssetUI) 2332 2333 const worstSwapPct = swap.estimate.realisticWorstCase / swappedInParentUnits * 100 2334 page.vSwapFeesHighPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(worstSwapPct)}%)` 2335 page.vSwapFeesHigh.textContent = Doc.formatCoinValue(swap.estimate.realisticWorstCase, fromFeeAssetUI) 2336 2337 const swapFeesMaxPct = swap.estimate.maxFees / swappedInParentUnits * 100 2338 page.vSwapFeesMaxPct.textContent = fromExchangeRate <= 0 ? '' : `(${fmtPct(swapFeesMaxPct)}%)` 2339 page.vSwapFeesMax.textContent = Doc.formatCoinValue(swap.estimate.maxFees, fromFeeAssetUI) 2340 2341 // Set redemption fee estimates in the details pane. 2342 const midGap = this.midGap() 2343 const estRate = midGap || order.rate / rateConversionFactor 2344 const received = order.sell ? swapped * estRate : swapped / estRate 2345 const receivedInParentUnits = toExchangeRate > 0 ? received / toExchangeRate : received 2346 2347 const bestRedeemPct = redeem.estimate.realisticBestCase / receivedInParentUnits * 100 2348 page.vRedeemFeesLowPct.textContent = toExchangeRate <= 0 ? '' : `(${fmtPct(bestRedeemPct)}%)` 2349 page.vRedeemFeesLow.textContent = Doc.formatCoinValue(redeem.estimate.realisticBestCase, toFeeAssetUI) 2350 2351 const worstRedeemPct = redeem.estimate.realisticWorstCase / receivedInParentUnits * 100 2352 page.vRedeemFeesHighPct.textContent = toExchangeRate <= 0 ? '' : `(${fmtPct(worstRedeemPct)}%)` 2353 page.vRedeemFeesHigh.textContent = Doc.formatCoinValue(redeem.estimate.realisticWorstCase, toFeeAssetUI) 2354 2355 if (baseExchangeRate && quoteExchangeRate) { 2356 Doc.show(page.vFeeSummaryPct) 2357 Doc.hide(page.vFeeSummary) 2358 page.vFeeSummaryLow.textContent = fmtPct(bestSwapPct + bestRedeemPct) 2359 page.vFeeSummaryHigh.textContent = fmtPct(worstSwapPct + worstRedeemPct) 2360 } else { 2361 Doc.hide(page.vFeeSummaryPct) 2362 Doc.show(page.vFeeSummary) 2363 page.summarySwapFeesLow.textContent = page.vSwapFeesLow.textContent 2364 page.summarySwapFeesHigh.textContent = page.vSwapFeesHigh.textContent 2365 page.summaryRedeemFeesLow.textContent = page.vRedeemFeesLow.textContent 2366 page.summaryRedeemFeesHigh.textContent = page.vRedeemFeesHigh.textContent 2367 } 2368 } 2369 2370 async submitCancel () { 2371 // this will be the page.cancelSubmit button (evt.currentTarget) 2372 const page = this.page 2373 const cancelData = this.cancelData 2374 const order = cancelData.order 2375 const req = { 2376 orderID: order.id 2377 } 2378 // Toggle the loader and submit button. 2379 const loaded = app().loading(page.cancelSubmit) 2380 const res = await postJSON('/api/cancel', req) 2381 loaded() 2382 // Display error on confirmation modal. 2383 if (!app().checkResponse(res)) { 2384 page.cancelErr.textContent = res.msg 2385 Doc.show(page.cancelErr) 2386 return 2387 } 2388 // Hide confirmation modal only on success. 2389 Doc.hide(cancelData.bttn, page.forms) 2390 order.cancelling = true 2391 } 2392 2393 /* showCancel shows a form to confirm submission of a cancel order. */ 2394 showCancel (row: HTMLElement, orderID: string) { 2395 const ord = this.metaOrders[orderID].ord 2396 const page = this.page 2397 const remaining = ord.qty - ord.filled 2398 const asset = OrderUtil.isMarketBuy(ord) ? this.market.quote : this.market.base 2399 page.cancelRemain.textContent = Doc.formatCoinValue(remaining, asset.unitInfo) 2400 page.cancelUnit.textContent = asset.symbol.toUpperCase() 2401 Doc.hide(page.cancelErr) 2402 this.forms.show(page.cancelForm) 2403 this.cancelData = { 2404 bttn: Doc.tmplElement(row, 'cancelBttn'), 2405 order: ord 2406 } 2407 } 2408 2409 /* showAccelerate shows the accelerate order form. */ 2410 showAccelerate (order: Order) { 2411 const loaded = app().loading(this.main) 2412 this.accelerateOrderForm.refresh(order) 2413 loaded() 2414 this.forms.show(this.page.accelerateForm) 2415 } 2416 2417 /* showCreate shows the new wallet creation form. */ 2418 showCreate (asset: SupportedAsset) { 2419 const page = this.page 2420 this.currentCreate = asset 2421 this.newWalletForm.setAsset(asset.id) 2422 this.forms.show(page.newWalletForm) 2423 } 2424 2425 /* 2426 * stepSubmit will examine the current state of wallets and step the user 2427 * through the process of order submission. 2428 * NOTE: I expect this process will be streamlined soon such that the wallets 2429 * will attempt to be unlocked in the order submission process, negating the 2430 * need to unlock ahead of time. 2431 */ 2432 stepSubmit () { 2433 const page = this.page 2434 const market = this.market 2435 Doc.hide(page.orderErr) 2436 if (!this.validateOrder(this.parseOrder())) return 2437 const baseWallet = app().walletMap[market.base.id] 2438 const quoteWallet = app().walletMap[market.quote.id] 2439 if (!baseWallet) { 2440 page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.base.symbol }) 2441 Doc.show(page.orderErr) 2442 return 2443 } 2444 if (!quoteWallet) { 2445 page.orderErr.textContent = intl.prep(intl.ID_NO_ASSET_WALLET, { asset: market.quote.symbol }) 2446 Doc.show(page.orderErr) 2447 return 2448 } 2449 this.showVerify() 2450 } 2451 2452 /* Display a deposit address. */ 2453 async showDeposit (assetID: number) { 2454 this.depositAddrForm.setAsset(assetID) 2455 this.forms.show(this.page.deposit) 2456 } 2457 2458 showCustomProviderDialog (assetID: number) { 2459 app().loadPage('wallets', { promptProvider: assetID, goBack: 'markets' }) 2460 } 2461 2462 /* 2463 * handlePriceUpdate is the handler for the 'spots' notification. 2464 */ 2465 handlePriceUpdate (note: SpotPriceNote) { 2466 if (!this.market) return // This note can arrive before the market is set. 2467 if (note.host === this.market.dex.host && note.spots[this.market.cfg.name]) { 2468 this.setCurrMarketPrice() 2469 } 2470 this.marketList.updateSpots(note) 2471 } 2472 2473 async handleWalletState (note: WalletStateNote) { 2474 if (!this.market) return // This note can arrive before the market is set. 2475 // if (note.topic !== 'TokenApproval') return 2476 if (note.wallet.assetID !== this.market.base?.id && note.wallet.assetID !== this.market.quote?.id) return 2477 this.setTokenApprovalVisibility() 2478 this.resolveOrderFormVisibility() 2479 } 2480 2481 /* 2482 * handleBondUpdate is the handler for the 'bondpost' notification type. 2483 * This is used to update the registration status of the current exchange. 2484 */ 2485 async handleBondUpdate (note: BondNote) { 2486 const dexAddr = note.dex 2487 if (!this.market) return // This note can arrive before the market is set. 2488 if (dexAddr !== this.market.dex.host) return 2489 // If we just finished legacy registration, we need to update the Exchange. 2490 // TODO: Use tier change notification once available. 2491 if (note.topic === 'AccountRegistered') await app().fetchUser() 2492 // Update local copy of Exchange. 2493 this.market.dex = app().exchanges[dexAddr] 2494 this.setRegistrationStatusVisibility() 2495 this.updateReputation() 2496 } 2497 2498 updateReputation () { 2499 const { page, market: { dex: { host }, cfg: mkt, baseCfg: { unitInfo: bui }, quoteCfg: { unitInfo: qui } } } = this 2500 const { auth } = app().exchanges[host] 2501 2502 page.parcelSizeLots.textContent = String(mkt.parcelsize) 2503 page.marketLimitBase.textContent = Doc.formatFourSigFigs(mkt.parcelsize * mkt.lotsize / bui.conventional.conversionFactor) 2504 page.marketLimitBaseUnit.textContent = bui.conventional.unit 2505 page.marketLimitQuoteUnit.textContent = qui.conventional.unit 2506 const conversionRate = this.anyRate()[1] 2507 if (conversionRate) { 2508 const quoteLot = mkt.lotsize * conversionRate 2509 page.marketLimitQuote.textContent = Doc.formatFourSigFigs(mkt.parcelsize * quoteLot / qui.conventional.conversionFactor) 2510 } else page.marketLimitQuote.textContent = '-' 2511 2512 const tier = strongTier(auth) 2513 page.tradingTier.textContent = String(tier) 2514 const [usedParcels, parcelLimit] = tradingLimits(host) 2515 page.tradingLimit.textContent = (parcelLimit * mkt.parcelsize).toFixed(2) 2516 page.limitUsage.textContent = parcelLimit > 0 ? (usedParcels / parcelLimit * 100).toFixed(1) : '0' 2517 2518 page.orderLimitRemain.textContent = ((parcelLimit - usedParcels) * mkt.parcelsize).toFixed(1) 2519 page.orderTradingTier.textContent = String(tier) 2520 2521 this.reputationMeter.update() 2522 } 2523 2524 /* 2525 * anyRate finds the best rate from any of, in order of priority, the order 2526 * book, the server's reported spot rate, or the fiat exchange rates. A 2527 * 3-tuple of message-rate encoding, a conversion rate, and a conventional 2528 * rate is generated. 2529 */ 2530 anyRate (): [number, number, number] { 2531 const { cfg: { spot }, baseCfg: { id: baseID }, quoteCfg: { id: quoteID }, rateConversionFactor, bookLoaded } = this.market 2532 if (bookLoaded) { 2533 const midGap = this.midGap() 2534 if (midGap) return [midGap * OrderUtil.RateEncodingFactor, midGap, this.midGapConventional() || 0] 2535 } 2536 if (spot && spot.rate) return [spot.rate, spot.rate / OrderUtil.RateEncodingFactor, spot.rate / rateConversionFactor] 2537 const [baseUSD, quoteUSD] = [app().fiatRatesMap[baseID], app().fiatRatesMap[quoteID]] 2538 if (baseUSD && quoteUSD) { 2539 const conventionalRate = baseUSD / quoteUSD 2540 const msgRate = conventionalRate * rateConversionFactor 2541 const conversionRate = msgRate / OrderUtil.RateEncodingFactor 2542 return [msgRate, conversionRate, conventionalRate] 2543 } 2544 return [0, 0, 0] 2545 } 2546 2547 handleMatchNote (note: MatchNote) { 2548 const mord = this.metaOrders[note.orderID] 2549 const match = note.match 2550 if (!mord) return this.refreshActiveOrders() 2551 else if (mord.ord.type === OrderUtil.Market && match.status === OrderUtil.NewlyMatched) { // Update the average market rate display. 2552 // Fetch and use the updated order. 2553 const ord = app().order(note.orderID) 2554 if (ord) mord.details.rate.textContent = mord.header.rate.textContent = this.marketOrderRateString(ord, this.market) 2555 } 2556 if ( 2557 (match.side === OrderUtil.MatchSideMaker && match.status === OrderUtil.MakerRedeemed) || 2558 (match.side === OrderUtil.MatchSideTaker && match.status === OrderUtil.MatchComplete) 2559 ) this.updateReputation() 2560 if (app().canAccelerateOrder(mord.ord)) Doc.show(mord.details.accelerateBttn) 2561 else Doc.hide(mord.details.accelerateBttn) 2562 } 2563 2564 /* 2565 * handleOrderNote is the handler for the 'order'-type notification, which are 2566 * used to update a user's order's status. 2567 */ 2568 handleOrderNote (note: OrderNote) { 2569 const ord = note.order 2570 const mord = this.metaOrders[ord.id] 2571 // - If metaOrder doesn't exist for the given order it means it was created 2572 // via bwctl and the GUI isn't aware of it or it was an inflight order. 2573 // refreshActiveOrders must be called to grab this order. 2574 // - If an OrderLoaded notification is recieved, it means an order that was 2575 // previously not "ready to tick" (due to its wallets not being connected 2576 // and unlocked) has now become ready to tick. The active orders section 2577 // needs to be refreshed. 2578 const wasInflight = note.topic === 'AsyncOrderFailure' || note.topic === 'AsyncOrderSubmitted' 2579 if (!mord || wasInflight || (note.topic === 'OrderLoaded' && ord.readyToTick)) { 2580 return this.refreshActiveOrders() 2581 } 2582 const oldStatus = mord.ord.status 2583 mord.ord = ord 2584 if (note.topic === 'MissedCancel') Doc.show(mord.details.cancelBttn) 2585 if (ord.filled === ord.qty) Doc.hide(mord.details.cancelBttn) 2586 if (app().canAccelerateOrder(ord)) Doc.show(mord.details.accelerateBttn) 2587 else Doc.hide(mord.details.accelerateBttn) 2588 this.updateMetaOrder(mord) 2589 // Only reset markers if there is a change, since the chart is redrawn. 2590 if ( 2591 (oldStatus === OrderUtil.StatusEpoch && ord.status === OrderUtil.StatusBooked) || 2592 (oldStatus === OrderUtil.StatusBooked && ord.status > OrderUtil.StatusBooked) 2593 ) { 2594 this.setDepthMarkers() 2595 this.updateReputation() 2596 this.mm.readBook() 2597 } 2598 } 2599 2600 /* 2601 * handleEpochNote handles notifications signalling the start of a new epoch. 2602 */ 2603 handleEpochNote (note: EpochNote) { 2604 app().log('book', 'handleEpochNote:', note) 2605 if (!this.market) return // This note can arrive before the market is set. 2606 if (note.host !== this.market.dex.host || note.marketID !== this.market.sid) return 2607 if (this.book) { 2608 this.book.setEpoch(note.epoch) 2609 this.depthChart.draw() 2610 } 2611 2612 this.clearOrderTableEpochs() 2613 for (const { ord, details, header } of Object.values(this.metaOrders)) { 2614 const alreadyMatched = note.epoch > ord.epoch 2615 switch (true) { 2616 case ord.type === OrderUtil.Limit && ord.status === OrderUtil.StatusEpoch && alreadyMatched: { 2617 const status = ord.tif === OrderUtil.ImmediateTiF ? intl.prep(intl.ID_EXECUTED) : intl.prep(intl.ID_BOOKED) 2618 details.status.textContent = header.status.textContent = status 2619 ord.status = ord.tif === OrderUtil.ImmediateTiF ? OrderUtil.StatusExecuted : OrderUtil.StatusBooked 2620 break 2621 } 2622 case ord.type === OrderUtil.Market && ord.status === OrderUtil.StatusEpoch: 2623 // Technically don't know if this should be 'executed' or 'settling'. 2624 details.status.textContent = header.status.textContent = intl.prep(intl.ID_EXECUTED) 2625 ord.status = OrderUtil.StatusExecuted 2626 break 2627 } 2628 } 2629 } 2630 2631 /* 2632 * recentMatchesSortCompare returns sort compare function according to the active 2633 * sort key and direction. 2634 */ 2635 recentMatchesSortCompare () { 2636 switch (this.recentMatchesSortKey) { 2637 case 'rate': 2638 return (a: RecentMatch, b: RecentMatch) => this.recentMatchesSortDirection * (a.rate - b.rate) 2639 case 'qty': 2640 return (a: RecentMatch, b: RecentMatch) => this.recentMatchesSortDirection * (a.qty - b.qty) 2641 case 'age': 2642 return (a: RecentMatch, b:RecentMatch) => this.recentMatchesSortDirection * (a.stamp - b.stamp) 2643 } 2644 } 2645 2646 refreshRecentMatchesTable () { 2647 const page = this.page 2648 const recentMatches = this.recentMatches 2649 Doc.empty(page.recentMatchesLiveList) 2650 if (!recentMatches) return 2651 const compare = this.recentMatchesSortCompare() 2652 recentMatches.sort(compare) 2653 for (const match of recentMatches) { 2654 const row = page.recentMatchesTemplate.cloneNode(true) as HTMLElement 2655 const tmpl = Doc.parseTemplate(row) 2656 app().bindTooltips(row) 2657 tmpl.rate.textContent = Doc.formatCoinValue(match.rate / this.market.rateConversionFactor) 2658 tmpl.qty.textContent = Doc.formatCoinValue(match.qty, this.market.baseUnitInfo) 2659 tmpl.age.textContent = Doc.timeSince(match.stamp) 2660 tmpl.age.dataset.sinceStamp = String(match.stamp) 2661 row.classList.add(match.sell ? 'sellcolor' : 'buycolor') 2662 page.recentMatchesLiveList.append(row) 2663 } 2664 } 2665 2666 addRecentMatches (matches: RecentMatch[]) { 2667 this.recentMatches = [...matches, ...this.recentMatches].slice(0, 100) 2668 } 2669 2670 /* handleBalanceNote handles notifications updating a wallet's balance. */ 2671 handleBalanceNote (note: BalanceNote) { 2672 this.approveTokenForm.handleBalanceNote(note) 2673 this.preorderCache = {} // invalidate previous preorder results 2674 // if connection to dex server fails, it is not possible to retrieve 2675 // markets. 2676 const mkt = this.market 2677 if (!mkt || !mkt.dex || mkt.dex.connectionStatus !== ConnectionStatus.Connected) return 2678 2679 this.mm.handleBalanceNote(note) 2680 const wgt = this.balanceWgt 2681 // Display the widget if the balance note is for its base or quote wallet. 2682 if ((note.assetID === wgt.base.id || note.assetID === wgt.quote.id)) wgt.setBalanceVisibility(true) 2683 2684 // If there's a balance update, refresh the max order section. 2685 const avail = note.balance.available 2686 switch (note.assetID) { 2687 case mkt.baseCfg.id: 2688 // If we're not showing the max order panel yet, don't do anything. 2689 if (!mkt.maxSell) break 2690 if (typeof mkt.sellBalance === 'number' && mkt.sellBalance !== avail) mkt.maxSell = null 2691 if (this.isSell()) this.preSell() 2692 break 2693 case mkt.quoteCfg.id: 2694 if (!Object.keys(mkt.maxBuys).length) break 2695 if (typeof mkt.buyBalance === 'number' && mkt.buyBalance !== avail) mkt.maxBuys = {} 2696 if (!this.isSell()) this.preBuy() 2697 } 2698 } 2699 2700 /* 2701 * submitOrder is attached to the affirmative button on the order validation 2702 * form. Clicking the button is the last step in the order submission process. 2703 */ 2704 async submitOrder () { 2705 const page = this.page 2706 Doc.hide(page.orderErr, page.vErr) 2707 const order = this.currentOrder 2708 const req = { order: wireOrder(order) } 2709 if (!this.validateOrder(order)) return 2710 // Show loader and hide submit button. 2711 page.vSubmit.classList.add('d-hide') 2712 page.vLoader.classList.remove('d-hide') 2713 const res = await postJSON('/api/tradeasync', req) 2714 // Hide loader and show submit button. 2715 page.vSubmit.classList.remove('d-hide') 2716 page.vLoader.classList.add('d-hide') 2717 // If error, display error on confirmation modal. 2718 if (!app().checkResponse(res)) { 2719 page.vErr.textContent = res.msg 2720 Doc.show(page.vErr) 2721 return 2722 } 2723 // Hide confirmation modal only on success. 2724 Doc.hide(page.forms) 2725 this.refreshActiveOrders() 2726 } 2727 2728 /* 2729 * createWallet is attached to successful submission of the wallet creation 2730 * form. createWallet is only called once the form is submitted and a success 2731 * response is received from the client. 2732 */ 2733 async createWallet () { 2734 const user = await app().fetchUser() 2735 if (!user) return 2736 const asset = user.assets[this.currentCreate.id] 2737 Doc.hide(this.page.forms) 2738 const mkt = this.market 2739 if (mkt.baseCfg.id === asset.id) mkt.base = asset 2740 else if (mkt.quoteCfg.id === asset.id) mkt.quote = asset 2741 this.balanceWgt.updateAsset(asset.id) 2742 this.displayMessageIfMissingWallet() 2743 this.resolveOrderFormVisibility() 2744 } 2745 2746 /* lotChanged is attached to the keyup and change events of the lots input. */ 2747 lotChanged () { 2748 const page = this.page 2749 const lots = parseInt(page.lotField.value || '0') 2750 if (lots <= 0) { 2751 page.lotField.value = page.lotField.value === '' ? '' : '0' 2752 page.qtyField.value = '' 2753 this.previewQuoteAmt(false) 2754 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) 2755 return 2756 } 2757 const lotSize = this.market.cfg.lotsize 2758 const orderQty = lots * lotSize 2759 page.lotField.value = String(lots) 2760 // Conversion factor must be a multiple of 10. 2761 page.qtyField.value = String(orderQty / this.market.baseUnitInfo.conventional.conversionFactor) 2762 2763 if (!this.isLimit() && this.isSell()) { 2764 const baseWallet = app().assets[this.market.base.id].wallet 2765 this.setOrderBttnEnabled(orderQty <= baseWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_SELL_BALANCE_ERROR)) 2766 } 2767 this.previewQuoteAmt(true) 2768 } 2769 2770 /* 2771 * quantityChanged is attached to the keyup and change events of the quantity 2772 * input. 2773 */ 2774 quantityChanged (finalize: boolean) { 2775 const page = this.page 2776 const order = this.currentOrder = this.parseOrder() 2777 if (order.qty < 0) { 2778 page.lotField.value = '0' 2779 page.qtyField.value = '' 2780 this.previewQuoteAmt(false) 2781 return 2782 } 2783 const lotSize = this.market.cfg.lotsize 2784 const lots = Math.floor(order.qty / lotSize) 2785 const adjusted = order.qty = this.currentOrder.qty = lots * lotSize 2786 page.lotField.value = String(lots) 2787 2788 if (!order.isLimit && !order.sell) return 2789 2790 // Conversion factor must be a multiple of 10. 2791 if (finalize) page.qtyField.value = String(adjusted / this.market.baseUnitInfo.conventional.conversionFactor) 2792 this.previewQuoteAmt(true) 2793 } 2794 2795 /* 2796 * marketBuyChanged is attached to the keyup and change events of the quantity 2797 * input for the market-buy form. 2798 */ 2799 marketBuyChanged () { 2800 const page = this.page 2801 const qty = convertToAtoms(page.mktBuyField.value || '', this.market.quoteUnitInfo.conventional.conversionFactor) 2802 const gap = this.midGap() 2803 if (qty > 0) { 2804 const quoteWallet = app().assets[this.market.quote.id].wallet 2805 this.setOrderBttnEnabled(qty <= quoteWallet.balance.available, intl.prep(intl.ID_ORDER_BUTTON_BUY_BALANCE_ERROR)) 2806 } else { 2807 this.setOrderBttnEnabled(false, intl.prep(intl.ID_ORDER_BUTTON_QTY_ERROR)) 2808 } 2809 if (!gap || !qty) { 2810 page.mktBuyLots.textContent = '0' 2811 page.mktBuyScore.textContent = '0' 2812 return 2813 } 2814 const lotSize = this.market.cfg.lotsize 2815 const received = qty / gap 2816 const lots = (received / lotSize) 2817 page.mktBuyLots.textContent = lots.toFixed(1) 2818 page.mktBuyScore.textContent = Doc.formatCoinValue(received, this.market.baseUnitInfo) 2819 } 2820 2821 /* 2822 * rateFieldChanged is attached to the keyup and change events of the rate 2823 * input. 2824 */ 2825 rateFieldChanged () { 2826 // Truncate to rate step. If it is a market buy order, do not adjust. 2827 const adjusted = this.adjustedRate() 2828 if (adjusted <= 0) { 2829 this.depthLines.input = [] 2830 this.drawChartLines() 2831 this.page.rateField.value = '0' 2832 this.previewQuoteAmt(true) 2833 this.updateOrderBttnState() 2834 return 2835 } 2836 const order = this.currentOrder = this.parseOrder() 2837 const r = adjusted / this.market.rateConversionFactor 2838 this.page.rateField.value = String(r) 2839 this.depthLines.input = [{ 2840 rate: r, 2841 color: order.sell ? this.depthChart.theme.sellLine : this.depthChart.theme.buyLine 2842 }] 2843 this.drawChartLines() 2844 this.previewQuoteAmt(true) 2845 this.updateOrderBttnState() 2846 } 2847 2848 /* 2849 * adjustedRate is the current rate field rate, rounded down to a 2850 * multiple of rateStep. 2851 */ 2852 adjustedRate (): number { 2853 const v = this.page.rateField.value 2854 if (!v) return NaN 2855 const rate = convertToAtoms(v, this.market.rateConversionFactor) 2856 const rateStep = this.market.cfg.ratestep 2857 return rate - (rate % rateStep) 2858 } 2859 2860 /* loadTable reloads the table from the current order book information. */ 2861 loadTable () { 2862 this.loadTableSide(true) 2863 this.loadTableSide(false) 2864 } 2865 2866 /* binOrdersByRateAndEpoch takes a list of sorted orders and returns the 2867 same orders grouped into arrays. The orders are grouped by their rate 2868 and whether or not they are epoch queue orders. Epoch queue orders 2869 will come after non epoch queue orders with the same rate. */ 2870 binOrdersByRateAndEpoch (orders: MiniOrder[]) { 2871 if (!orders || !orders.length) return [] 2872 const bins = [] 2873 let currEpochBin = [] 2874 let currNonEpochBin = [] 2875 let currRate = orders[0].msgRate 2876 if (orders[0].epoch) currEpochBin.push(orders[0]) 2877 else currNonEpochBin.push(orders[0]) 2878 for (let i = 1; i < orders.length; i++) { 2879 if (orders[i].msgRate !== currRate) { 2880 bins.push(currNonEpochBin) 2881 bins.push(currEpochBin) 2882 currEpochBin = [] 2883 currNonEpochBin = [] 2884 currRate = orders[i].msgRate 2885 } 2886 if (orders[i].epoch) currEpochBin.push(orders[i]) 2887 else currNonEpochBin.push(orders[i]) 2888 } 2889 bins.push(currNonEpochBin) 2890 bins.push(currEpochBin) 2891 return bins.filter(bin => bin.length > 0) 2892 } 2893 2894 /* loadTables loads the order book side into its table. */ 2895 loadTableSide (sell: boolean) { 2896 const bookSide = sell ? this.book.sells : this.book.buys 2897 const tbody = sell ? this.page.sellRows : this.page.buyRows 2898 Doc.empty(tbody) 2899 if (!bookSide || !bookSide.length) return 2900 const orderBins = this.binOrdersByRateAndEpoch(bookSide) 2901 orderBins.forEach(bin => { tbody.appendChild(this.orderTableRow(bin)) }) 2902 } 2903 2904 /* addTableOrder adds a single order to the appropriate table. */ 2905 addTableOrder (order: MiniOrder) { 2906 const tbody = order.sell ? this.page.sellRows : this.page.buyRows 2907 let row = tbody.firstChild as OrderRow 2908 // Handle market order differently. 2909 if (order.rate === 0) { 2910 if (order.qtyAtomic === 0) return // a cancel order. TODO: maybe make an indicator on the target order, maybe gray out 2911 // This is a market order. 2912 if (row && row.manager.getRate() === 0) { 2913 row.manager.insertOrder(order) 2914 } else { 2915 row = this.orderTableRow([order]) 2916 tbody.insertBefore(row, tbody.firstChild) 2917 } 2918 return 2919 } 2920 // Must be a limit order. Sort by rate. Skip the market order row. 2921 if (row && row.manager.getRate() === 0) row = row.nextSibling as OrderRow 2922 while (row) { 2923 if (row.manager.compare(order) === 0) { 2924 row.manager.insertOrder(order) 2925 return 2926 } else if (row.manager.compare(order) > 0) { 2927 const tr = this.orderTableRow([order]) 2928 tbody.insertBefore(tr, row) 2929 return 2930 } 2931 row = row.nextSibling as OrderRow 2932 } 2933 const tr = this.orderTableRow([order]) 2934 tbody.appendChild(tr) 2935 } 2936 2937 /* removeTableOrder removes a single order from its table. */ 2938 removeTableOrder (order: MiniOrder) { 2939 const token = order.token 2940 for (const tbody of [this.page.sellRows, this.page.buyRows]) { 2941 for (const tr of (Array.from(tbody.children) as OrderRow[])) { 2942 if (tr.manager.removeOrder(token)) { 2943 return 2944 } 2945 } 2946 } 2947 } 2948 2949 /* updateTableOrder looks for the order in the table and updates the qty */ 2950 updateTableOrder (u: RemainderUpdate) { 2951 for (const tbody of [this.page.sellRows, this.page.buyRows]) { 2952 for (const tr of (Array.from(tbody.children) as OrderRow[])) { 2953 if (tr.manager.updateOrderQty(u)) { 2954 return 2955 } 2956 } 2957 } 2958 } 2959 2960 /* 2961 * clearOrderTableEpochs removes immediate-tif orders whose epoch has expired. 2962 */ 2963 clearOrderTableEpochs () { 2964 this.clearOrderTableEpochSide(this.page.sellRows) 2965 this.clearOrderTableEpochSide(this.page.buyRows) 2966 } 2967 2968 /* 2969 * clearOrderTableEpochs removes immediate-tif orders whose epoch has expired 2970 * for a single side. 2971 */ 2972 clearOrderTableEpochSide (tbody: HTMLElement) { 2973 for (const tr of (Array.from(tbody.children)) as OrderRow[]) { 2974 tr.manager.removeEpochOrders() 2975 } 2976 } 2977 2978 /* 2979 * orderTableRow creates a new <tr> element to insert into an order table. 2980 Takes a bin of orders with the same rate, and displays the total quantity. 2981 */ 2982 orderTableRow (orderBin: MiniOrder[]): OrderRow { 2983 const tr = this.page.orderRowTmpl.cloneNode(true) as OrderRow 2984 const { baseUnitInfo, quoteUnitInfo, rateConversionFactor, cfg: { ratestep: rateStep } } = this.market 2985 const manager = new OrderTableRowManager(tr, orderBin, baseUnitInfo, quoteUnitInfo, rateStep) 2986 tr.manager = manager 2987 bind(tr, 'click', () => { 2988 this.reportDepthClick(tr.manager.getRate() / rateConversionFactor) 2989 }) 2990 if (tr.manager.getRate() !== 0) { 2991 Doc.bind(tr, 'mouseenter', () => { 2992 const chart = this.depthChart 2993 this.depthLines.hover = [{ 2994 rate: tr.manager.getRate() / rateConversionFactor, 2995 color: tr.manager.isSell() ? chart.theme.sellLine : chart.theme.buyLine 2996 }] 2997 this.drawChartLines() 2998 }) 2999 } 3000 return tr 3001 } 3002 3003 /* handleConnNote handles the 'conn' notification. 3004 */ 3005 async handleConnNote (note: ConnEventNote) { 3006 this.marketList.setConnectionStatus(note) 3007 if (note.topic === 'DEXDisabled' || note.topic === 'DEXEnabled' || note.connectionStatus === ConnectionStatus.Connected) { 3008 // Having been disconnected or connected from a DEX server, anything may 3009 // have changed, or this may be the first opportunity to get the server's 3010 // config, so fetch it all before reloading the markets page. 3011 await app().fetchUser() 3012 await app().loadPage('markets') 3013 } 3014 } 3015 3016 /* 3017 * filterMarkets sets the display of markets in the markets list based on the 3018 * value of the search input. 3019 */ 3020 filterMarkets () { 3021 const filterTxt = this.page.marketSearchV1.value?.toLowerCase() 3022 const filter = filterTxt ? (mkt: MarketRow) => mkt.name.includes(filterTxt) : () => true 3023 this.marketList.setFilter(filter) 3024 } 3025 3026 /* drawChartLines draws the hover and input lines on the chart. */ 3027 drawChartLines () { 3028 this.depthChart.setLines([...this.depthLines.hover, ...this.depthLines.input]) 3029 this.depthChart.draw() 3030 } 3031 3032 /* candleDurationSelected sets the candleDur and loads the candles. It will 3033 default to the oneHrBinKey if dur is not valid. */ 3034 candleDurationSelected (dur: string) { 3035 if (!this.market?.dex?.candleDurs.includes(dur)) dur = oneHrBinKey 3036 this.candleDur = dur 3037 this.loadCandles() 3038 State.storeLocal(State.lastCandleDurationLK, dur) 3039 } 3040 3041 /* 3042 * loadCandles loads the candles for the current candleDur. If a cache is already 3043 * active, the cache will be used without a loadcandles request. 3044 */ 3045 loadCandles () { 3046 for (const bttn of Doc.kids(this.page.durBttnBox)) { 3047 if (bttn.textContent === this.candleDur) bttn.classList.add('selected') 3048 else bttn.classList.remove('selected') 3049 } 3050 const { candleCaches, cfg, baseUnitInfo, quoteUnitInfo } = this.market 3051 const cache = candleCaches[this.candleDur] 3052 if (cache) { 3053 // this.depthChart.hide() 3054 // this.candleChart.show() 3055 this.candleChart.setCandles(cache, cfg, baseUnitInfo, quoteUnitInfo) 3056 return 3057 } 3058 this.requestCandles() 3059 } 3060 3061 /* requestCandles sends the loadcandles request. It accepts an optional candle 3062 * duration which will be requested if it is provided. 3063 */ 3064 requestCandles (candleDur?: string) { 3065 this.candlesLoading = { 3066 loaded: () => { /* pass */ }, 3067 timer: window.setTimeout(() => { 3068 if (this.candlesLoading) { 3069 this.candlesLoading = null 3070 console.error('candles not received') 3071 } 3072 }, 10000) 3073 } 3074 const { dex, baseCfg, quoteCfg } = this.market 3075 ws.request('loadcandles', { host: dex.host, base: baseCfg.id, quote: quoteCfg.id, dur: candleDur || this.candleDur }) 3076 } 3077 3078 /* 3079 * unload is called by the Application when the user navigates away from 3080 * the /markets page. 3081 */ 3082 unload () { 3083 ws.request(unmarketRoute, {}) 3084 ws.deregisterRoute(bookRoute) 3085 ws.deregisterRoute(bookOrderRoute) 3086 ws.deregisterRoute(unbookOrderRoute) 3087 ws.deregisterRoute(updateRemainingRoute) 3088 ws.deregisterRoute(epochOrderRoute) 3089 ws.deregisterRoute(candlesRoute) 3090 ws.deregisterRoute(candleUpdateRoute) 3091 this.depthChart.unattach() 3092 this.candleChart.unattach() 3093 Doc.unbind(document, 'keyup', this.keyup) 3094 clearInterval(this.secondTicker) 3095 } 3096 } 3097 3098 /* 3099 * MarketList represents the list of exchanges and markets on the left side of 3100 * markets view. The MarketList provides utilities for adjusting the visibility 3101 * and sort order of markets. 3102 */ 3103 class MarketList { 3104 // xcSections: ExchangeSection[] 3105 div: PageElement 3106 rowTmpl: PageElement 3107 markets: MarketRow[] 3108 selected: MarketRow 3109 3110 constructor (div: HTMLElement) { 3111 this.div = div 3112 this.rowTmpl = Doc.idel(div, 'marketTmplV1') 3113 Doc.cleanTemplates(this.rowTmpl) 3114 this.reloadMarketsPane() 3115 } 3116 3117 updateSpots (note: SpotPriceNote) { 3118 for (const row of this.markets) { 3119 if (row.mkt.xc.host !== note.host) continue 3120 const xc = app().exchanges[row.mkt.xc.host] 3121 const mkt = xc.markets[row.mkt.name] 3122 setPriceAndChange(row.tmpl, xc, mkt) 3123 } 3124 } 3125 3126 reloadMarketsPane (): void { 3127 Doc.empty(this.div) 3128 this.markets = [] 3129 3130 const addMarket = (mkt: ExchangeMarket) => { 3131 const bui = app().unitInfo(mkt.baseid, mkt.xc) 3132 const qui = app().unitInfo(mkt.quoteid, mkt.xc) 3133 const rateConversionFactor = OrderUtil.RateEncodingFactor / bui.conventional.conversionFactor * qui.conventional.conversionFactor 3134 const row = new MarketRow(this.rowTmpl, mkt, rateConversionFactor) 3135 this.div.appendChild(row.node) 3136 return row 3137 } 3138 3139 for (const mkt of sortedMarkets()) this.markets.push(addMarket(mkt)) 3140 app().bindTooltips(this.div) 3141 } 3142 3143 find (host: string, baseID: number, quoteID: number): MarketRow | null { 3144 for (const row of this.markets) { 3145 if (row.mkt.xc.host === host && row.mkt.baseid === baseID && row.mkt.quoteid === quoteID) return row 3146 } 3147 return null 3148 } 3149 3150 /* exists will be true if the specified market exists. */ 3151 exists (host: string, baseID: number, quoteID: number): boolean { 3152 return this.find(host, baseID, quoteID) !== null 3153 } 3154 3155 /* first gets the first market from the first exchange, alphabetically. */ 3156 first (): MarketRow { 3157 return this.markets[0] 3158 } 3159 3160 /* select sets the specified market as selected. */ 3161 select (host: string, baseID: number, quoteID: number) { 3162 const row = this.find(host, baseID, quoteID) 3163 if (!row) return console.error(`select: no market row for ${host}, ${baseID}-${quoteID}`) 3164 for (const mkt of this.markets) mkt.node.classList.remove('selected') 3165 this.selected = row 3166 this.selected.node.classList.add('selected') 3167 } 3168 3169 /* setConnectionStatus sets the visibility of the disconnected icon based 3170 * on the core.ConnEventNote. 3171 */ 3172 setConnectionStatus (note: ConnEventNote) { 3173 for (const row of this.markets) { 3174 if (row.mkt.xc.host !== note.host) continue 3175 if (note.connectionStatus === ConnectionStatus.Connected) Doc.hide(row.tmpl.disconnectedIco) 3176 else Doc.show(row.tmpl.disconnectedIco) 3177 } 3178 } 3179 3180 /* 3181 * setFilter sets the visibility of market rows based on the provided filter. 3182 */ 3183 setFilter (filter: (mkt: MarketRow) => boolean) { 3184 for (const row of this.markets) { 3185 if (filter(row)) Doc.show(row.node) 3186 else Doc.hide(row.node) 3187 } 3188 } 3189 } 3190 3191 /* 3192 * MarketRow represents one row in the MarketList. A MarketRow is a subsection 3193 * of the ExchangeSection. 3194 */ 3195 class MarketRow { 3196 node: HTMLElement 3197 mkt: ExchangeMarket 3198 name: string 3199 baseID: number 3200 quoteID: number 3201 lotSize: number 3202 tmpl: Record<string, PageElement> 3203 rateConversionFactor: number 3204 3205 constructor (template: HTMLElement, mkt: ExchangeMarket, rateConversionFactor: number) { 3206 this.mkt = mkt 3207 this.name = mkt.name 3208 this.baseID = mkt.baseid 3209 this.quoteID = mkt.quoteid 3210 this.lotSize = mkt.lotsize 3211 this.rateConversionFactor = rateConversionFactor 3212 this.node = template.cloneNode(true) as HTMLElement 3213 const tmpl = this.tmpl = Doc.parseTemplate(this.node) 3214 tmpl.baseIcon.src = Doc.logoPath(mkt.basesymbol) 3215 tmpl.quoteIcon.src = Doc.logoPath(mkt.quotesymbol) 3216 tmpl.baseSymbol.appendChild(Doc.symbolize(mkt.xc.assets[mkt.baseid], true)) 3217 tmpl.quoteSymbol.appendChild(Doc.symbolize(mkt.xc.assets[mkt.quoteid], true)) 3218 tmpl.baseName.textContent = mkt.baseName 3219 tmpl.host.textContent = mkt.xc.host 3220 tmpl.host.style.color = hostColor(mkt.xc.host) 3221 tmpl.host.dataset.tooltip = mkt.xc.host 3222 setPriceAndChange(tmpl, mkt.xc, mkt) 3223 if (this.mkt.xc.connectionStatus !== ConnectionStatus.Connected) Doc.show(tmpl.disconnectedIco) 3224 } 3225 } 3226 3227 interface BalanceWidgetElement { 3228 id: number 3229 parentID: number 3230 cfg: Asset | null 3231 node: PageElement 3232 tmpl: Record<string, PageElement> 3233 iconBox: PageElement 3234 stateIcons: WalletIcons 3235 parentBal?: PageElement 3236 } 3237 3238 /* 3239 * BalanceWidget is a display of balance information. Because the wallet can be 3240 * in any number of states, and because every exchange has different funding 3241 * coin confirmation requirements, the BalanceWidget displays a number of state 3242 * indicators and buttons, as well as tabulated balance data with rows for 3243 * locked and immature balance. 3244 */ 3245 class BalanceWidget { 3246 base: BalanceWidgetElement 3247 quote: BalanceWidgetElement 3248 // parentRow: PageElement 3249 dex: Exchange 3250 3251 constructor (base: HTMLElement, quote: HTMLElement) { 3252 Doc.hide(base, quote) 3253 const btmpl = Doc.parseTemplate(base) 3254 this.base = { 3255 id: 0, 3256 parentID: parentIDNone, 3257 cfg: null, 3258 node: base, 3259 tmpl: btmpl, 3260 iconBox: btmpl.walletState, 3261 stateIcons: new WalletIcons(btmpl.walletState) 3262 } 3263 btmpl.balanceRowTmpl.remove() 3264 3265 const qtmpl = Doc.parseTemplate(quote) 3266 this.quote = { 3267 id: 0, 3268 parentID: parentIDNone, 3269 cfg: null, 3270 node: quote, 3271 tmpl: qtmpl, 3272 iconBox: qtmpl.walletState, 3273 stateIcons: new WalletIcons(qtmpl.walletState) 3274 } 3275 qtmpl.balanceRowTmpl.remove() 3276 3277 app().registerNoteFeeder({ 3278 balance: (note: BalanceNote) => { this.updateAsset(note.assetID) }, 3279 walletstate: (note: WalletStateNote) => { this.updateAsset(note.wallet.assetID) }, 3280 walletsync: (note: WalletSyncNote) => { this.updateAsset(note.assetID) }, 3281 createwallet: (note: WalletCreationNote) => { this.updateAsset(note.assetID) } 3282 }) 3283 } 3284 3285 setBalanceVisibility (connected: boolean) { 3286 if (connected) Doc.show(this.base.node, this.quote.node) 3287 else Doc.hide(this.base.node, this.quote.node) 3288 } 3289 3290 /* 3291 * setWallet sets the balance widget to display data for specified market and 3292 * will display the widget. 3293 */ 3294 setWallets (host: string, baseID: number, quoteID: number) { 3295 const parentID = (assetID: number) => { 3296 const asset = app().assets[assetID] 3297 if (asset?.token) return asset.token.parentID 3298 return parentIDNone 3299 } 3300 this.dex = app().user.exchanges[host] 3301 this.base.id = baseID 3302 this.base.parentID = parentID(baseID) 3303 this.base.cfg = this.dex.assets[baseID] 3304 this.quote.id = quoteID 3305 this.quote.parentID = parentID(quoteID) 3306 this.quote.cfg = this.dex.assets[quoteID] 3307 this.updateWallet(this.base) 3308 this.updateWallet(this.quote) 3309 this.setBalanceVisibility(this.dex.connectionStatus === ConnectionStatus.Connected) 3310 } 3311 3312 /* 3313 * updateWallet updates the displayed wallet information based on the 3314 * core.Wallet state. 3315 */ 3316 updateWallet (side: BalanceWidgetElement) { 3317 const { cfg, tmpl, iconBox, stateIcons, id: assetID } = side 3318 if (!cfg) return // no wallet set yet 3319 const asset = app().assets[assetID] 3320 // Just hide everything to start. 3321 Doc.hide( 3322 tmpl.newWalletRow, tmpl.expired, tmpl.unsupported, tmpl.connect, tmpl.spinner, 3323 tmpl.walletState, tmpl.balanceRows, tmpl.walletAddr, tmpl.wantProvidersBox 3324 ) 3325 this.checkNeedsProvider(assetID, tmpl.wantProvidersBox) 3326 tmpl.logo.src = Doc.logoPath(cfg.symbol) 3327 tmpl.addWalletSymbol.textContent = cfg.symbol.toUpperCase() 3328 Doc.empty(tmpl.symbol) 3329 3330 // Handle an unsupported asset. 3331 if (!asset) { 3332 Doc.show(tmpl.unsupported) 3333 return 3334 } 3335 tmpl.symbol.appendChild(Doc.symbolize(asset, true)) 3336 Doc.show(iconBox) 3337 const wallet = asset.wallet 3338 stateIcons.readWallet(wallet) 3339 // Handle no wallet configured. 3340 if (!wallet) { 3341 if (asset.walletCreationPending) { 3342 Doc.show(tmpl.spinner) 3343 return 3344 } 3345 Doc.show(tmpl.newWalletRow) 3346 return 3347 } 3348 Doc.show(tmpl.walletAddr) 3349 // Parent asset 3350 const bal = wallet.balance 3351 // Handle not connected and no balance known for the DEX. 3352 if (!bal && !wallet.running && !wallet.disabled) { 3353 Doc.show(tmpl.connect) 3354 return 3355 } 3356 // If there is no balance, but the wallet is connected, show the loading 3357 // icon while we fetch an update. 3358 if (!bal) { 3359 app().fetchBalance(assetID) 3360 Doc.show(tmpl.spinner) 3361 return 3362 } 3363 3364 // We have a wallet and a DEX-specific balance. Set all of the fields. 3365 Doc.show(tmpl.balanceRows) 3366 Doc.empty(tmpl.balanceRows) 3367 const addRow = (title: string, bal: number, ui: UnitInfo, icon?: PageElement) => { 3368 const row = tmpl.balanceRowTmpl.cloneNode(true) as PageElement 3369 tmpl.balanceRows.appendChild(row) 3370 const balTmpl = Doc.parseTemplate(row) 3371 balTmpl.title.textContent = title 3372 balTmpl.bal.textContent = Doc.formatCoinValue(bal, ui) 3373 if (icon) { 3374 balTmpl.bal.append(icon) 3375 side.parentBal = balTmpl.bal 3376 } 3377 } 3378 addRow(intl.prep(intl.ID_AVAILABLE), bal.available, asset.unitInfo) 3379 addRow(intl.prep(intl.ID_LOCKED), bal.locked + bal.contractlocked + bal.bondlocked, asset.unitInfo) 3380 addRow(intl.prep(intl.ID_IMMATURE), bal.immature, asset.unitInfo) 3381 if (asset.token) { 3382 const { wallet: { balance }, unitInfo, symbol } = app().assets[asset.token.parentID] 3383 const icon = document.createElement('img') 3384 icon.src = Doc.logoPath(symbol) 3385 icon.classList.add('micro-icon', 'ms-1') 3386 addRow(intl.prep(intl.ID_FEE_BALANCE), balance.available, unitInfo, icon) 3387 } 3388 3389 // If the current balance update time is older than an hour, show the 3390 // expiration icon. Request a balance update, if possible. 3391 const expired = new Date().getTime() - new Date(bal.stamp).getTime() > anHour 3392 if (expired && !wallet.disabled) { 3393 Doc.show(tmpl.expired) 3394 if (wallet.running) app().fetchBalance(assetID) 3395 } else Doc.hide(tmpl.expired) 3396 } 3397 3398 async checkNeedsProvider (assetID: number, el: PageElement) { 3399 Doc.setVis(await app().needsCustomProvider(assetID), el) 3400 } 3401 3402 /* updateParent updates the side's parent asset balance. */ 3403 updateParent (side: BalanceWidgetElement) { 3404 const { wallet: { balance }, unitInfo } = app().assets[side.parentID] 3405 // firstChild is the text node set before the img child node in addRow. 3406 if (side.parentBal?.firstChild) side.parentBal.firstChild.textContent = Doc.formatCoinValue(balance.available, unitInfo) 3407 } 3408 3409 /* 3410 * updateAsset updates the info for one side of the existing market. If the 3411 * specified asset ID is not one of the current market's base or quote assets, 3412 * it is silently ignored. 3413 */ 3414 updateAsset (assetID: number) { 3415 if (assetID === this.base.id) this.updateWallet(this.base) 3416 else if (assetID === this.quote.id) this.updateWallet(this.quote) 3417 if (assetID === this.base.parentID) this.updateParent(this.base) 3418 if (assetID === this.quote.parentID) this.updateParent(this.quote) 3419 } 3420 } 3421 3422 /* makeMarket creates a market object that specifies basic market details. */ 3423 function makeMarket (host: string, base?: number, quote?: number) { 3424 return { 3425 host: host, 3426 base: base, 3427 quote: quote 3428 } 3429 } 3430 3431 /* marketID creates a DEX-compatible market name from the ticker symbols. */ 3432 export function marketID (b: string, q: string) { return `${b}_${q}` } 3433 3434 /* convertToAtoms converts the float string to the basic unit of a coin. */ 3435 function convertToAtoms (s: string, conversionFactor: number) { 3436 if (!s) return 0 3437 return Math.round(parseFloat(s) * conversionFactor) 3438 } 3439 3440 /* swapBttns changes the 'selected' class of the buttons. */ 3441 function swapBttns (before: HTMLElement, now: HTMLElement) { 3442 before.classList.remove('selected') 3443 now.classList.add('selected') 3444 } 3445 3446 /* 3447 * wireOrder prepares a copy of the order with the options field converted to a 3448 * string -> string map. 3449 */ 3450 function wireOrder (order: TradeForm) { 3451 const stringyOptions: Record<string, string> = {} 3452 for (const [k, v] of Object.entries(order.options)) stringyOptions[k] = JSON.stringify(v) 3453 return Object.assign({}, order, { options: stringyOptions }) 3454 } 3455 3456 // OrderTableRowManager manages the data within a row in an order table. Each row 3457 // represents all the orders in the order book with the same rate, but orders that 3458 // are booked or still in the epoch queue are displayed in separate rows. 3459 class OrderTableRowManager { 3460 tableRow: HTMLElement 3461 page: Record<string, PageElement> 3462 orderBin: MiniOrder[] 3463 sell: boolean 3464 msgRate: number 3465 epoch: boolean 3466 baseUnitInfo: UnitInfo 3467 3468 constructor (tableRow: HTMLElement, orderBin: MiniOrder[], baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo, rateStep: number) { 3469 this.tableRow = tableRow 3470 const page = this.page = Doc.parseTemplate(tableRow) 3471 this.orderBin = orderBin 3472 this.sell = orderBin[0].sell 3473 this.msgRate = orderBin[0].msgRate 3474 this.epoch = !!orderBin[0].epoch 3475 this.baseUnitInfo = baseUnitInfo 3476 const rateText = Doc.formatRateFullPrecision(this.msgRate, baseUnitInfo, quoteUnitInfo, rateStep) 3477 Doc.setVis(this.isEpoch(), this.page.epoch) 3478 if (this.msgRate === 0) { 3479 page.rate.innerText = 'market' 3480 } else { 3481 const cssClass = this.isSell() ? 'sellcolor' : 'buycolor' 3482 page.rate.innerText = rateText 3483 page.rate.classList.add(cssClass) 3484 } 3485 this.updateQtyNumOrdersEl() 3486 } 3487 3488 // updateQtyNumOrdersEl populates the quantity element in the row, and also 3489 // displays the number of orders if there is more than one order in the order 3490 // bin. 3491 updateQtyNumOrdersEl () { 3492 const { page, orderBin } = this 3493 const qty = orderBin.reduce((total, curr) => total + curr.qtyAtomic, 0) 3494 const numOrders = orderBin.length 3495 page.qty.innerText = Doc.formatFullPrecision(qty, this.baseUnitInfo) 3496 if (numOrders > 1) { 3497 page.numOrders.removeAttribute('hidden') 3498 page.numOrders.innerText = String(numOrders) 3499 page.numOrders.title = `quantity is comprised of ${numOrders} orders` 3500 } else { 3501 page.numOrders.setAttribute('hidden', 'true') 3502 } 3503 } 3504 3505 // insertOrder adds an order to the order bin and updates the row elements 3506 // accordingly. 3507 insertOrder (order: MiniOrder) { 3508 this.orderBin.push(order) 3509 this.updateQtyNumOrdersEl() 3510 } 3511 3512 // updateOrderQuantity updates the quantity of the order identified by a token, 3513 // if it exists in the row, and updates the row elements accordingly. The function 3514 // returns true if the order is in the bin, and false otherwise. 3515 updateOrderQty (update: RemainderUpdate) { 3516 const { token, qty, qtyAtomic } = update 3517 for (let i = 0; i < this.orderBin.length; i++) { 3518 if (this.orderBin[i].token === token) { 3519 this.orderBin[i].qty = qty 3520 this.orderBin[i].qtyAtomic = qtyAtomic 3521 this.updateQtyNumOrdersEl() 3522 return true 3523 } 3524 } 3525 return false 3526 } 3527 3528 // removeOrder removes the order identified by the token, if it exists in the row, 3529 // and updates the row elements accordingly. If the order bin is empty, the row is 3530 // removed from the screen. The function returns true if an order was removed, and 3531 // false otherwise. 3532 removeOrder (token: string) { 3533 const index = this.orderBin.findIndex(order => order.token === token) 3534 if (index < 0) return false 3535 this.orderBin.splice(index, 1) 3536 if (!this.orderBin.length) this.tableRow.remove() 3537 else this.updateQtyNumOrdersEl() 3538 return true 3539 } 3540 3541 // removeEpochOrders removes all the orders from the row that are not in the 3542 // new epoch's epoch queue and updates the elements accordingly. 3543 removeEpochOrders (newEpoch?: number) { 3544 this.orderBin = this.orderBin.filter((order) => { 3545 return !(order.epoch && order.epoch !== newEpoch) 3546 }) 3547 if (!this.orderBin.length) this.tableRow.remove() 3548 else this.updateQtyNumOrdersEl() 3549 } 3550 3551 // getRate returns the rate of the orders in the row. 3552 getRate () { 3553 return this.msgRate 3554 } 3555 3556 // isEpoch returns whether the orders in this row are in the epoch queue. 3557 isEpoch () { 3558 return this.epoch 3559 } 3560 3561 // isSell returns whether the orders in this row are sell orders. 3562 isSell () { 3563 return this.sell 3564 } 3565 3566 // compare takes an order and returns 0 if the order belongs in this row, 3567 // 1 if the order should go after this row in the table, and -1 if it should 3568 // be before this row in the table. Sell orders are displayed in ascending order, 3569 // buy orders are displayed in descending order, and epoch orders always come 3570 // after booked orders. 3571 compare (order: MiniOrder) { 3572 if (this.getRate() === order.msgRate && this.isEpoch() === !!order.epoch) { 3573 return 0 3574 } else if (this.getRate() !== order.msgRate) { 3575 return (this.getRate() > order.msgRate) === order.sell ? 1 : -1 3576 } else { 3577 return this.isEpoch() ? 1 : -1 3578 } 3579 } 3580 } 3581 3582 interface ExchangeMarket extends Market { 3583 xc: Exchange 3584 baseName: string 3585 bui: UnitInfo 3586 } 3587 3588 function sortedMarkets (): ExchangeMarket[] { 3589 const mkts: ExchangeMarket[] = [] 3590 const assets = app().assets 3591 const convertMarkets = (xc: Exchange, mkts: Market[]) => { 3592 return mkts.map((mkt: Market) => { 3593 const a = assets[mkt.baseid] 3594 const baseName = a ? a.name : mkt.basesymbol 3595 const bui = app().unitInfo(mkt.baseid, xc) 3596 return Object.assign({ xc, baseName, bui }, mkt) 3597 }) 3598 } 3599 for (const xc of Object.values(app().exchanges)) mkts.push(...convertMarkets(xc, Object.values(xc.markets || {}))) 3600 mkts.sort((a: ExchangeMarket, b: ExchangeMarket): number => { 3601 if (!a.spot) { 3602 if (b.spot) return 1 // put b first, since we have the spot 3603 // no spots. compare market name then host name 3604 if (a.name === b.name) return a.xc.host.localeCompare(b.xc.host) 3605 return a.name.localeCompare(b.name) 3606 } else if (!b.spot) return -1 // put a first, since we have the spot 3607 const [aLots, bLots] = [a.spot.vol24 / a.lotsize, b.spot.vol24 / b.lotsize] 3608 return bLots - aLots // whoever has more volume by lot count 3609 }) 3610 return mkts 3611 } 3612 3613 function setPriceAndChange (tmpl: Record<string, PageElement>, xc: Exchange, mkt: Market) { 3614 if (!mkt.spot) return 3615 tmpl.price.textContent = Doc.formatFourSigFigs(app().conventionalRate(mkt.baseid, mkt.quoteid, mkt.spot.rate, xc)) 3616 const sign = mkt.spot.change24 > 0 ? '+' : '' 3617 tmpl.change.classList.remove('buycolor', 'sellcolor') 3618 tmpl.change.classList.add(mkt.spot.change24 >= 0 ? 'buycolor' : 'sellcolor') 3619 tmpl.change.textContent = `${sign}${(mkt.spot.change24 * 100).toFixed(1)}%` 3620 } 3621 3622 const hues = [1 / 2, 1 / 4, 3 / 4, 1 / 8, 5 / 8, 3 / 8, 7 / 8] 3623 3624 function generateHue (idx: number): string { 3625 const h = hues[idx % hues.length] 3626 return `hsl(${h * 360}, 35%, 50%)` 3627 } 3628 3629 function hostColor (host: string): string { 3630 const hosts = Object.keys(app().exchanges) 3631 hosts.sort() 3632 return generateHue(hosts.indexOf(host)) 3633 }