decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/order.ts (about) 1 import Doc from './doc' 2 import BasePage from './basepage' 3 import * as OrderUtil from './orderutil' 4 import { bind as bindForm, AccelerateOrderForm } from './forms' 5 import { postJSON } from './http' 6 import * as intl from './locales' 7 import { 8 app, 9 Order, 10 PageElement, 11 OrderNote, 12 MatchNote, 13 Match, 14 Coin 15 } from './registry' 16 import { setOptionTemplates } from './opts' 17 import { formatCoinID, setCoinHref } from './coinexplorers' 18 19 // lockTimeMakerMs must match the value returned from LockTimeMaker func in 20 // bisonw. 21 const lockTimeMakerMs = 20 * 60 * 60 * 1000 22 // lockTimeTakerMs must match the value returned from LockTimeTaker func in 23 // bisonw. 24 const lockTimeTakerMs = 8 * 60 * 60 * 1000 25 26 const animationLength = 500 27 28 export default class OrderPage extends BasePage { 29 orderID: string 30 order: Order 31 page: Record<string, PageElement> 32 currentForm: HTMLElement 33 secondTicker: number 34 refreshOnPopupClose: boolean 35 accelerateOrderForm: AccelerateOrderForm 36 stampers: PageElement[] 37 38 constructor (main: HTMLElement) { 39 super() 40 const page = this.page = Doc.idDescendants(main) 41 this.stampers = Doc.applySelector(main, '[data-stamp]') 42 // Find the order 43 this.orderID = main.dataset.oid || '' 44 45 Doc.cleanTemplates(page.matchCardTmpl) 46 47 const setStamp = () => { 48 for (const span of this.stampers) { 49 span.textContent = Doc.timeSince(parseInt(span.dataset.stamp || '')) 50 } 51 } 52 setStamp() 53 54 page.forms.querySelectorAll('.form-closer').forEach(el => { 55 Doc.bind(el, 'click', () => { 56 if (this.refreshOnPopupClose) { 57 window.location.replace(window.location.href) 58 return 59 } 60 Doc.hide(page.forms) 61 }) 62 }) 63 64 // Some static elements on this page contain assets that can be linked 65 // to blockchain explorers (such as Etherscan) so users can easily 66 // examine funding/acceleration coins data there. We'd need to set up 67 // such hyperlinks here. 68 main.querySelectorAll('[data-explorer-id]').forEach((link: PageElement) => { 69 const assetID = parseInt(link.dataset.explorerId || '') 70 setCoinHref(assetID, link) 71 }) 72 73 if (page.cancelBttn) { 74 Doc.bind(page.cancelBttn, 'click', () => { 75 this.showForm(page.cancelForm) 76 }) 77 } 78 79 Doc.bind(page.accelerateBttn, 'click', () => { 80 this.showAccelerateForm() 81 }) 82 83 const success = () => { 84 this.refreshOnPopupClose = true 85 } 86 // Do not call cleanTemplates before creating the AccelerateOrderForm 87 setOptionTemplates(page) 88 this.accelerateOrderForm = new AccelerateOrderForm(page.accelerateForm, success) 89 Doc.cleanTemplates(page.booleanOptTmpl, page.rangeOptTmpl, page.orderOptTmpl) 90 91 // If the user clicks outside of a form, it should close the page overlay. 92 Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => { 93 if (!Doc.mouseInElement(e, this.currentForm)) { 94 if (this.refreshOnPopupClose) { 95 window.location.reload() 96 return 97 } 98 Doc.hide(page.forms) 99 } 100 }) 101 102 // Cancel order form 103 bindForm(page.cancelForm, page.cancelSubmit, async () => { this.submitCancel() }) 104 105 this.secondTicker = window.setInterval(() => { 106 setStamp() 107 }, 10000) // update every 10 seconds 108 109 app().registerNoteFeeder({ 110 order: (note: OrderNote) => { this.handleOrderNote(note) }, 111 match: (note: MatchNote) => { this.handleMatchNote(note) } 112 }) 113 114 this.start() 115 } 116 117 async start () { 118 let ord = app().order(this.orderID) 119 // app().order can only access active orders. If the order is not active, 120 // we'll need to get the data from the database. 121 if (ord) this.order = ord 122 else { 123 ord = await this.fetchOrder() 124 } 125 // Swap out the dot-notation symbols with token-aware symbols. 126 this.page.mktBaseSymbol.replaceWith(Doc.symbolize(app().assets[ord.baseID])) 127 this.page.mktQuoteSymbol.replaceWith(Doc.symbolize(app().assets[ord.quoteID])) 128 129 this.setAccelerationButtonVis() 130 this.showMatchCards() 131 } 132 133 unload () { 134 clearInterval(this.secondTicker) 135 } 136 137 /* fetchOrder fetches the order from the client. */ 138 async fetchOrder (): Promise<Order> { 139 const res = await postJSON('/api/order', this.orderID) 140 if (!app().checkResponse(res)) throw res.msg 141 this.order = res.order 142 return this.order 143 } 144 145 /* 146 * setImmutableMatchCardElements sets the match card elements that are never 147 * changed. 148 */ 149 setImmutableMatchCardElements (matchCard: HTMLElement, match: Match) { 150 const tmpl = Doc.parseTemplate(matchCard) 151 152 tmpl.matchID.textContent = match.matchID 153 154 const time = new Date(match.stamp) 155 tmpl.matchTime.textContent = time.toLocaleTimeString(Doc.languages(), { 156 year: 'numeric', 157 month: 'short', 158 day: 'numeric' 159 }) 160 161 tmpl.matchTimeAgo.dataset.stamp = match.stamp.toString() 162 tmpl.matchTimeAgo.textContent = Doc.timeSince(match.stamp) 163 this.stampers.push(tmpl.matchTimeAgo) 164 165 const orderPortion = OrderUtil.orderPortion(this.order, match) 166 const baseSymbol = Doc.bipSymbol(this.order.baseID) 167 const quoteSymbol = Doc.bipSymbol(this.order.quoteID) 168 const baseUnitInfo = app().unitInfo(this.order.baseID) 169 const quoteUnitInfo = app().unitInfo(this.order.quoteID) 170 const [bUnit, qUnit] = [baseUnitInfo.conventional.unit.toLowerCase(), quoteUnitInfo.conventional.unit.toLowerCase()] 171 const quoteAmount = OrderUtil.baseToQuote(match.rate, match.qty) 172 173 if (match.isCancel) { 174 Doc.show(tmpl.cancelInfoDiv) 175 Doc.hide(tmpl.infoDiv, tmpl.status, tmpl.statusHdr) 176 177 if (this.order.sell) { 178 tmpl.cancelAmount.textContent = Doc.formatCoinValue(match.qty, baseUnitInfo) 179 tmpl.cancelIcon.src = Doc.logoPathFromID(this.order.baseID) 180 } else { 181 tmpl.cancelAmount.textContent = Doc.formatCoinValue(quoteAmount, quoteUnitInfo) 182 tmpl.cancelIcon.src = Doc.logoPathFromID(this.order.quoteID) 183 } 184 185 tmpl.cancelOrderPortion.textContent = orderPortion 186 187 return 188 } 189 190 Doc.show(tmpl.infoDiv) 191 Doc.hide(tmpl.cancelInfoDiv) 192 193 tmpl.orderPortion.textContent = orderPortion 194 195 if (match.side === OrderUtil.Maker) { 196 tmpl.side.textContent = intl.prep(intl.ID_MAKER) 197 Doc.show( 198 tmpl.makerSwapYou, 199 tmpl.makerRedeemYou, 200 tmpl.takerSwapThem, 201 tmpl.takerRedeemThem 202 ) 203 Doc.hide( 204 tmpl.takerSwapYou, 205 tmpl.takerRedeemYou, 206 tmpl.makerSwapThem, 207 tmpl.makerRedeemThem 208 ) 209 } else { 210 tmpl.side.textContent = intl.prep(intl.ID_TAKER) 211 Doc.hide( 212 tmpl.makerSwapYou, 213 tmpl.makerRedeemYou, 214 tmpl.takerSwapThem, 215 tmpl.takerRedeemThem 216 ) 217 Doc.show( 218 tmpl.takerSwapYou, 219 tmpl.takerRedeemYou, 220 tmpl.makerSwapThem, 221 tmpl.makerRedeemThem 222 ) 223 } 224 225 if ((match.side === OrderUtil.Maker && this.order.sell) || 226 (match.side === OrderUtil.Taker && !this.order.sell)) { 227 tmpl.makerSwapAsset.textContent = bUnit 228 tmpl.takerSwapAsset.textContent = qUnit 229 tmpl.makerRedeemAsset.textContent = qUnit 230 tmpl.takerRedeemAsset.textContent = bUnit 231 } else { 232 tmpl.makerSwapAsset.textContent = qUnit 233 tmpl.takerSwapAsset.textContent = bUnit 234 tmpl.makerRedeemAsset.textContent = bUnit 235 tmpl.takerRedeemAsset.textContent = qUnit 236 } 237 238 const rate = app().conventionalRate(this.order.baseID, this.order.quoteID, match.rate) 239 tmpl.rate.textContent = `${rate} ${bUnit}/${qUnit}` 240 241 if (this.order.sell) { 242 tmpl.refundAsset.textContent = baseSymbol 243 tmpl.fromAmount.textContent = Doc.formatCoinValue(match.qty, baseUnitInfo) 244 tmpl.toAmount.textContent = Doc.formatCoinValue(quoteAmount, quoteUnitInfo) 245 tmpl.fromIcon.src = Doc.logoPathFromID(this.order.baseID) 246 tmpl.toIcon.src = Doc.logoPathFromID(this.order.quoteID) 247 } else { 248 tmpl.refundAsset.textContent = quoteSymbol 249 tmpl.fromAmount.textContent = Doc.formatCoinValue(quoteAmount, quoteUnitInfo) 250 tmpl.toAmount.textContent = Doc.formatCoinValue(match.qty, baseUnitInfo) 251 tmpl.fromIcon.src = Doc.logoPathFromID(this.order.quoteID) 252 tmpl.toIcon.src = Doc.logoPathFromID(this.order.baseID) 253 } 254 } 255 256 /* 257 * setMutableMatchCardElements sets the match card elements which may get 258 * updated on each update to the match. 259 */ 260 setMutableMatchCardElements (matchCard: HTMLElement, m: Match) { 261 if (m.isCancel) return 262 263 const tmpl = Doc.parseTemplate(matchCard) 264 tmpl.status.textContent = OrderUtil.matchStatusString(m) 265 266 const tryShowCoin = (pendingEl: PageElement, coinLink: PageElement, coin: Coin) => { 267 if (!coin) { 268 Doc.hide(coinLink) 269 Doc.show(pendingEl) 270 return 271 } 272 coinLink.textContent = formatCoinID(coin.stringID) 273 coinLink.dataset.explorerCoin = coin.stringID 274 setCoinHref(coin.assetID, coinLink) 275 Doc.show(coinLink) 276 Doc.hide(pendingEl) 277 } 278 279 tryShowCoin(tmpl.makerSwapPending, tmpl.makerSwapCoin, makerSwapCoin(m)) 280 tryShowCoin(tmpl.takerSwapPending, tmpl.takerSwapCoin, takerSwapCoin(m)) 281 tryShowCoin(tmpl.makerRedeemPending, tmpl.makerRedeemCoin, makerRedeemCoin(m)) 282 tryShowCoin(tmpl.takerRedeemPending, tmpl.takerRedeemCoin, takerRedeemCoin(m)) 283 if (!m.refund) { 284 // Special messaging for pending refunds. 285 let lockTime = lockTimeMakerMs 286 if (m.side === OrderUtil.Taker) lockTime = lockTimeTakerMs 287 const refundAfter = new Date(m.stamp + lockTime) 288 if (Date.now() > refundAfter.getTime()) tmpl.refundPending.textContent = intl.prep(intl.ID_REFUND_IMMINENT) 289 else { 290 const refundAfterStr = refundAfter.toLocaleTimeString(Doc.languages(), { 291 year: 'numeric', 292 month: 'short', 293 day: 'numeric' 294 }) 295 tmpl.refundPending.textContent = intl.prep(intl.ID_REFUND_WILL_HAPPEN_AFTER, { refundAfterTime: refundAfterStr }) 296 } 297 Doc.hide(tmpl.refundCoin) 298 Doc.show(tmpl.refundPending) 299 } else { 300 tmpl.refundCoin.textContent = formatCoinID(m.refund.stringID) 301 tmpl.refundCoin.dataset.explorerCoin = m.refund.stringID 302 setCoinHref(m.refund.assetID, tmpl.refundCoin) 303 Doc.show(tmpl.refundCoin) 304 Doc.hide(tmpl.refundPending) 305 } 306 307 if (m.status === OrderUtil.MakerSwapCast && !m.revoked && !m.refund) { 308 const c = makerSwapCoin(m) 309 tmpl.makerSwapMsg.textContent = confirmationString(c) 310 Doc.hide(tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) 311 Doc.show(tmpl.makerSwapMsg) 312 } else if (m.status === OrderUtil.TakerSwapCast && !m.revoked && !m.refund) { 313 const c = takerSwapCoin(m) 314 tmpl.takerSwapMsg.textContent = confirmationString(c) 315 Doc.hide(tmpl.makerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) 316 Doc.show(tmpl.takerSwapMsg) 317 } else if (inConfirmingMakerRedeem(m) && !m.revoked && !m.refund) { 318 tmpl.makerRedeemMsg.textContent = confirmationString(m.redeem) 319 Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.takerRedeemMsg) 320 Doc.show(tmpl.makerRedeemMsg) 321 } else if (inConfirmingTakerRedeem(m) && !m.revoked && !m.refund) { 322 tmpl.takerRedeemMsg.textContent = confirmationString(m.redeem) 323 Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg) 324 Doc.show(tmpl.takerRedeemMsg) 325 } else { 326 Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) 327 } 328 329 if (!m.revoked) { 330 // Match is still following the usual success-path, it is desirable for the 331 // user to see it in full (even if to learn how atomic swap is supposed to 332 // work). 333 334 Doc.setVis(makerSwapCoin(m) || m.active, tmpl.makerSwap) 335 Doc.setVis(takerSwapCoin(m) || m.active, tmpl.takerSwap) 336 Doc.setVis(makerRedeemCoin(m) || m.active, tmpl.makerRedeem) 337 // When maker isn't aware of taker redeem coin, once the match becomes inactive 338 // (nothing else maker is expected to do in this match) just hide taker redeem. 339 Doc.setVis(takerRedeemCoin(m) || m.active, tmpl.takerRedeem) 340 // Refunding isn't a usual part of success-path, but don't rule it out. 341 Doc.setVis(m.refund, tmpl.refund) 342 } else { 343 // Match diverged from the usual success-path, since this could have happened 344 // at any step it is hard (maybe impossible) to predict the final state this 345 // match will end up in, so show only steps that already happened plus all 346 // the possibilities on the next step ahead. 347 348 // If we don't have swap coins after revocation, we won't show the pending message. 349 Doc.setVis(makerSwapCoin(m), tmpl.makerSwap) 350 Doc.setVis(takerSwapCoin(m), tmpl.takerSwap) 351 const takerRefundsAfter = new Date(m.stamp + lockTimeTakerMs) 352 const takerLockTimeExpired = Date.now() > takerRefundsAfter.getTime() 353 // When match is revoked and both swaps are present, maker redeem might still show up: 354 // - as maker, we'll try to redeem until taker locktime expires (if taker refunds 355 // we won't be able to redeem; even if taker hasn't refunded just yet - it 356 // becomes too dangerous to redeem after taker locktime expired because maker 357 // reveals his secret when redeeming, and taker might be able to submit both 358 // redeem and refund transactions before maker's redeem gets mined), so we'll 359 // have to show redeem pending element until maker redeem shows up, or until 360 // we give up on redeeming due to taker locktime expiry. 361 // - as taker, we should expect maker redeeming any time, so we'll have to show 362 // redeem pending element until maker redeem shows up, or until we refund. 363 Doc.setVis(makerRedeemCoin(m) || (takerSwapCoin(m) && m.active && !m.refund && !takerLockTimeExpired), tmpl.makerRedeem) 364 // When maker isn't aware of taker redeem coin, once the match becomes inactive 365 // (nothing else maker is expected to do in this match) just hide taker redeem. 366 Doc.setVis(takerRedeemCoin(m) || (makerRedeemCoin(m) && m.active && !m.refund), tmpl.takerRedeem) 367 // As taker, show refund placeholder only if we have outstanding swap to refund. 368 // There is no need to wait for anything else, we can show refund placeholder 369 // (to inform the user that it is likely to happen) right after match revocation. 370 let expectingRefund = Boolean(takerSwapCoin(m)) // as taker 371 if (m.side === OrderUtil.Maker) { 372 // As maker, show refund placeholder only if we have outstanding swap to refund. 373 // If we don't have taker swap there is no need to wait for anything else, we 374 // can show refund placeholder (to inform the user that it is likely to happen) 375 // right after match revocation. 376 expectingRefund = Boolean(makerSwapCoin(m)) 377 // If we discover taker swap we'll be trying to redeem it (instead of trying 378 // to refund our own swap) until taker refunds, so start showing refund 379 // placeholder only after taker is expected to start his refund process in 380 // this case. 381 if (takerSwapCoin(m)) { 382 expectingRefund = expectingRefund && takerLockTimeExpired 383 } 384 } 385 Doc.setVis(m.refund || (m.active && !m.redeem && !m.counterRedeem && expectingRefund), tmpl.refund) 386 } 387 } 388 389 /* 390 * addNewMatchCard adds a new card to the list of match cards. 391 */ 392 addNewMatchCard (match: Match) { 393 const page = this.page 394 const matchCard = page.matchCardTmpl.cloneNode(true) as HTMLElement 395 app().bindUrlHandlers(matchCard) 396 matchCard.dataset.matchID = match.matchID 397 this.setImmutableMatchCardElements(matchCard, match) 398 this.setMutableMatchCardElements(matchCard, match) 399 page.matchBox.appendChild(matchCard) 400 } 401 402 /* 403 * showMatchCards creates cards for each match in the order. 404 */ 405 showMatchCards () { 406 const order = this.order 407 if (!order) return 408 if (!order.matches) return 409 order.matches.sort((a, b) => a.stamp - b.stamp) 410 order.matches.forEach((match) => this.addNewMatchCard(match)) 411 } 412 413 /* showCancel shows a form to confirm submission of a cancel order. */ 414 showCancel () { 415 const order = this.order 416 const page = this.page 417 const remaining = order.qty - order.filled 418 const asset = OrderUtil.isMarketBuy(order) ? app().assets[order.quoteID] : app().assets[order.baseID] 419 page.cancelRemain.textContent = Doc.formatCoinValue(remaining, asset.unitInfo) 420 page.cancelUnit.textContent = asset.unitInfo.conventional.unit.toUpperCase() 421 this.showForm(page.cancelForm) 422 } 423 424 /* showForm shows a modal form with a little animation. */ 425 async showForm (form: HTMLElement) { 426 this.currentForm = form 427 const page = this.page 428 Doc.hide(page.cancelForm, page.accelerateForm) 429 form.style.right = '10000px' 430 Doc.show(page.forms, form) 431 const shift = (page.forms.offsetWidth + form.offsetWidth) / 2 432 await Doc.animate(animationLength, progress => { 433 form.style.right = `${(1 - progress) * shift}px` 434 }, 'easeOutHard') 435 form.style.right = '0px' 436 } 437 438 /* submitCancel submits a cancellation for the order. */ 439 async submitCancel () { 440 // this will be the page.cancelSubmit button (evt.currentTarget) 441 const page = this.page 442 const order = this.order 443 const req = { 444 orderID: order.id 445 } 446 const loaded = app().loading(page.cancelForm) 447 const res = await postJSON('/api/cancel', req) 448 loaded() 449 if (!app().checkResponse(res)) return 450 page.status.textContent = intl.prep(intl.ID_CANCELING) 451 Doc.hide(page.forms) 452 order.cancelling = true 453 } 454 455 /* 456 * setAccelerationButtonVis shows the acceleration button if the order can 457 * be accelerated. 458 */ 459 setAccelerationButtonVis () { 460 const order = this.order 461 if (!order) return 462 const page = this.page 463 Doc.setVis(app().canAccelerateOrder(order), page.accelerateBttn, page.actionsLabel) 464 } 465 466 /* showAccelerateForm shows a form to accelerate an order */ 467 async showAccelerateForm () { 468 const loaded = app().loading(this.page.accelerateBttn) 469 this.accelerateOrderForm.refresh(this.order) 470 loaded() 471 this.showForm(this.page.accelerateForm) 472 } 473 474 /* 475 * handleOrderNote is the handler for the 'order'-type notification, which are 476 * used to update an order's status. 477 */ 478 handleOrderNote (note: OrderNote) { 479 const page = this.page 480 const order = note.order 481 if (order.id !== this.orderID) return 482 this.order = order 483 const bttn = page.cancelBttn 484 if (bttn && order.status > OrderUtil.StatusBooked) Doc.hide(bttn) 485 page.status.textContent = OrderUtil.statusString(order) 486 for (const m of order.matches || []) this.processMatch(m) 487 this.setAccelerationButtonVis() 488 } 489 490 /* handleMatchNote handles a 'match' notification. */ 491 handleMatchNote (note: MatchNote) { 492 if (note.orderID !== this.orderID) return 493 this.processMatch(note.match) 494 this.setAccelerationButtonVis() 495 } 496 497 /* 498 * processMatch synchronizes a match's card with a match received in a 499 * 'order' or 'match' notification. 500 */ 501 processMatch (m: Match) { 502 let card: HTMLElement | null = null 503 for (const div of Doc.applySelector(this.page.matchBox, '.match-card')) { 504 if (div.dataset.matchID === m.matchID) { 505 card = div 506 break 507 } 508 } 509 if (card) { 510 this.setMutableMatchCardElements(card, m) 511 } else { 512 this.addNewMatchCard(m) 513 } 514 } 515 } 516 517 /* 518 * confirmationString is a string describing the state of confirmations for a 519 * coin. 520 * */ 521 function confirmationString (coin: Coin) { 522 if (!coin.confs || coin.confs.required === 0) return '' 523 return `${coin.confs.count} / ${coin.confs.required} ${intl.prep(intl.ID_CONFIRMATIONS)}` 524 } 525 526 // makerSwapCoin return's the maker's swap coin. 527 function makerSwapCoin (m: Match) : Coin { 528 return (m.side === OrderUtil.Maker) ? m.swap : m.counterSwap 529 } 530 531 // takerSwapCoin return's the taker's swap coin. 532 function takerSwapCoin (m: Match) { 533 return (m.side === OrderUtil.Maker) ? m.counterSwap : m.swap 534 } 535 536 // makerRedeemCoin return's the maker's redeem coin. 537 function makerRedeemCoin (m: Match) { 538 return (m.side === OrderUtil.Maker) ? m.redeem : m.counterRedeem 539 } 540 541 // takerRedeemCoin return's the taker's redeem coin. 542 function takerRedeemCoin (m: Match) { 543 return (m.side === OrderUtil.Maker) ? m.counterRedeem : m.redeem 544 } 545 546 /* 547 * inConfirmingMakerRedeem will be true if we are the maker, and we are waiting 548 * on confirmations for our own redeem. 549 */ 550 function inConfirmingMakerRedeem (m: Match) { 551 return m.status < OrderUtil.MatchConfirmed && m.side === OrderUtil.Maker && m.status >= OrderUtil.MakerRedeemed 552 } 553 554 /* 555 * inConfirmingTakerRedeem will be true if we are the taker, and we are waiting 556 * on confirmations for our own redeem. 557 */ 558 function inConfirmingTakerRedeem (m: Match) { 559 return m.status < OrderUtil.MatchConfirmed && m.side === OrderUtil.Taker && m.status >= OrderUtil.MatchComplete 560 }