diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75884def7..d0520f59c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16.16.0", "18.5.0"] + node: ["16.16.0", "18.14.1", "19.6.1"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" @@ -55,7 +55,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" strategy: matrix: - node: ["16.15.0", "18.5.0"] + node: ["16.16.0", "18.14.1", "19.6.1"] flavor: ["dev", "prod"] fail-fast: false runs-on: "ubuntu-latest" diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index a0200c98c..6e4221857 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 53; + private static currentVersion = 54; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -86,7 +86,7 @@ class DatabaseMigration { try { await this.$migrateTableSchemaFromVersion(databaseSchemaVersion); if (databaseSchemaVersion === 0) { - logger.notice(`MIGRATIONS: OK. Database schema has been properly initialized to version ${DatabaseMigration.currentVersion} (latest version)`); + logger.notice(`MIGRATIONS: OK. Database schema has been properly initialized to version ${DatabaseMigration.currentVersion} (latest version)`); } else { logger.notice(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`); } @@ -300,7 +300,7 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"'); await this.updateToSchemaVersion(27); } - + if (databaseSchemaVersion < 28 && isBitcoin === true) { if (config.LIGHTNING.ENABLED) { this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`); @@ -464,7 +464,7 @@ class DatabaseMigration { await this.$executeQuery('DROP TABLE IF EXISTS `transactions`'); await this.$executeQuery('DROP TABLE IF EXISTS `cpfp_clusters`'); await this.updateToSchemaVersion(52); - } catch(e) { + } catch (e) { logger.warn('' + (e instanceof Error ? e.message : e)); } } @@ -473,6 +473,16 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE statistics MODIFY mempool_byte_weight bigint(20) UNSIGNED NOT NULL'); await this.updateToSchemaVersion(53); } + + if (databaseSchemaVersion < 54) { + this.uniqueLog(logger.notice, `'prices' table has been truncated`); + await this.$executeQuery(`TRUNCATE prices`); + if (isBitcoin === true) { + this.uniqueLog(logger.notice, `'blocks_prices' table has been truncated`); + await this.$executeQuery(`TRUNCATE blocks_prices`); + } + await this.updateToSchemaVersion(54); + } } /** @@ -596,7 +606,7 @@ class DatabaseMigration { queries.push(`INSERT INTO state(name, number, string) VALUES ('last_hashrates_indexing', 0, NULL)`); } - if (version < 9 && isBitcoin === true) { + if (version < 9 && isBitcoin === true) { queries.push(`INSERT INTO state(name, number, string) VALUES ('last_weekly_hashrates_indexing', 0, NULL)`); } diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 393ea119a..f7f392068 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -38,7 +38,16 @@ class MiningRoutes { private async $getHistoricalPrice(req: Request, res: Response): Promise { try { - res.status(200).send(await PricesRepository.$getHistoricalPrice()); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + if (req.query.timestamp) { + res.status(200).send(await PricesRepository.$getNearestHistoricalPrice( + parseInt(req.query.timestamp ?? 0, 10) + )); + } else { + res.status(200).send(await PricesRepository.$getHistoricalPrices()); + } } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 355187e21..df98719b9 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -521,7 +521,7 @@ class BlocksRepository { CAST(AVG(blocks.height) as INT) as avgHeight, CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, CAST(AVG(fees) as INT) as avgFees, - prices.* + prices.USD FROM blocks JOIN blocks_prices on blocks_prices.height = blocks.height JOIN prices on prices.id = blocks_prices.price_id @@ -550,7 +550,7 @@ class BlocksRepository { CAST(AVG(blocks.height) as INT) as avgHeight, CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, CAST(AVG(reward) as INT) as avgRewards, - prices.* + prices.USD FROM blocks JOIN blocks_prices on blocks_prices.height = blocks.height JOIN prices on prices.id = blocks_prices.price_id diff --git a/backend/src/repositories/PricesRepository.ts b/backend/src/repositories/PricesRepository.ts index 639f16dc6..83336eaff 100644 --- a/backend/src/repositories/PricesRepository.ts +++ b/backend/src/repositories/PricesRepository.ts @@ -28,6 +28,16 @@ export interface Conversion { exchangeRates: ExchangeRates; } +export const MAX_PRICES = { + USD: 100000000, + EUR: 100000000, + GBP: 100000000, + CAD: 100000000, + CHF: 100000000, + AUD: 100000000, + JPY: 10000000000, +}; + class PricesRepository { public async $savePrices(time: number, prices: IConversionRates): Promise { if (prices.USD === 0) { @@ -36,6 +46,14 @@ class PricesRepository { return; } + // Sanity check + for (const currency of Object.keys(prices)) { + if (prices[currency] < -1 || prices[currency] > MAX_PRICES[currency]) { // We use -1 to mark a "missing data, so it's a valid entry" + logger.info(`Ignore BTC${currency} price of ${prices[currency]}`); + prices[currency] = 0; + } + } + try { await DB.query(` INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY) @@ -86,9 +104,48 @@ class PricesRepository { return rates[0]; } - public async $getHistoricalPrice(): Promise { + public async $getNearestHistoricalPrice(timestamp: number | undefined): Promise { try { - const [rates]: any[] = await DB.query(`SELECT *, UNIX_TIMESTAMP(time) as time FROM prices ORDER BY time DESC`); + const [rates]: any[] = await DB.query(` + SELECT *, UNIX_TIMESTAMP(time) AS time + FROM prices + WHERE UNIX_TIMESTAMP(time) < ? + ORDER BY time DESC + LIMIT 1`, + [timestamp] + ); + if (!rates) { + throw Error(`Cannot get single historical price from the database`); + } + + // Compute fiat exchange rates + const latestPrice = await this.$getLatestConversionRates(); + const exchangeRates: ExchangeRates = { + USDEUR: Math.round(latestPrice.EUR / latestPrice.USD * 100) / 100, + USDGBP: Math.round(latestPrice.GBP / latestPrice.USD * 100) / 100, + USDCAD: Math.round(latestPrice.CAD / latestPrice.USD * 100) / 100, + USDCHF: Math.round(latestPrice.CHF / latestPrice.USD * 100) / 100, + USDAUD: Math.round(latestPrice.AUD / latestPrice.USD * 100) / 100, + USDJPY: Math.round(latestPrice.JPY / latestPrice.USD * 100) / 100, + }; + + return { + prices: rates, + exchangeRates: exchangeRates + }; + } catch (e) { + logger.err(`Cannot fetch single historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); + return null; + } + } + + public async $getHistoricalPrices(): Promise { + try { + const [rates]: any[] = await DB.query(` + SELECT *, UNIX_TIMESTAMP(time) AS time + FROM prices + ORDER BY time DESC + `); if (!rates) { throw Error(`Cannot get average historical price from the database`); } @@ -109,7 +166,7 @@ class PricesRepository { exchangeRates: exchangeRates }; } catch (e) { - logger.err(`Cannot fetch averaged historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); + logger.err(`Cannot fetch historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); return null; } } diff --git a/backend/src/tasks/price-updater.ts b/backend/src/tasks/price-updater.ts index 939a1ea85..b39e152ae 100644 --- a/backend/src/tasks/price-updater.ts +++ b/backend/src/tasks/price-updater.ts @@ -3,7 +3,7 @@ import path from 'path'; import config from '../config'; import logger from '../logger'; import { IConversionRates } from '../mempool.interfaces'; -import PricesRepository from '../repositories/PricesRepository'; +import PricesRepository, { MAX_PRICES } from '../repositories/PricesRepository'; import BitfinexApi from './price-feeds/bitfinex-api'; import BitflyerApi from './price-feeds/bitflyer-api'; import CoinbaseApi from './price-feeds/coinbase-api'; @@ -46,13 +46,13 @@ class PriceUpdater { public getEmptyPricesObj(): IConversionRates { return { - USD: 0, - EUR: 0, - GBP: 0, - CAD: 0, - CHF: 0, - AUD: 0, - JPY: 0, + USD: -1, + EUR: -1, + GBP: -1, + CAD: -1, + CHF: -1, + AUD: -1, + JPY: -1, }; } @@ -115,7 +115,7 @@ class PriceUpdater { if (feed.currencies.includes(currency)) { try { const price = await feed.$fetchPrice(currency); - if (price > 0) { + if (price > -1 && price < MAX_PRICES[currency]) { prices.push(price); } logger.debug(`${feed.name} BTC/${currency} price: ${price}`, logger.tags.mining); @@ -239,7 +239,7 @@ class PriceUpdater { for (const currency of this.currencies) { const price = historicalEntry[time][currency]; - if (price > 0) { + if (price > -1 && price < MAX_PRICES[currency]) { grouped[time][currency].push(typeof price === 'string' ? parseInt(price, 10) : price); } } diff --git a/frontend/src/app/components/about/about.component.scss b/frontend/src/app/components/about/about.component.scss index 42ecded1c..8390ce0ba 100644 --- a/frontend/src/app/components/about/about.component.scss +++ b/frontend/src/app/components/about/about.component.scss @@ -145,6 +145,13 @@ } } + .project-translators .wrapper { + a img { + width: 72px; + height: 72px; + } + } + .copyright { text-align: left; max-width: 620px; diff --git a/frontend/src/app/components/amount/amount.component.html b/frontend/src/app/components/amount/amount.component.html index 4a57e72e2..ce9c02d78 100644 --- a/frontend/src/app/components/amount/amount.component.html +++ b/frontend/src/app/components/amount/amount.component.html @@ -3,7 +3,7 @@ {{ addPlus && satoshis >= 0 ? '+' : '' }} {{ ( - (blockConversion.price[currency] > 0 ? blockConversion.price[currency] : null) ?? + (blockConversion.price[currency] >= 0 ? blockConversion.price[currency] : null) ?? (blockConversion.price['USD'] * blockConversion.exchangeRates['USD' + currency]) ?? 0 ) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency }} diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts index 96bd0697c..051d24848 100644 --- a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts @@ -1,19 +1,17 @@ import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; -import { Observable, Subscription } from 'rxjs'; +import { Observable } from 'rxjs'; import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { formatNumber } from '@angular/common'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { download, formatterXAxis } from '../../shared/graphs.utils'; -import { StateService } from '../../services/state.service'; import { StorageService } from '../../services/storage.service'; import { MiningService } from '../../services/mining.service'; import { ActivatedRoute } from '@angular/router'; import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; -import { fiatCurrencies } from '../../app.constants'; @Component({ selector: 'app-block-fees-graph', @@ -47,7 +45,6 @@ export class BlockFeesGraphComponent implements OnInit { timespan = ''; chartInstance: any = undefined; - currencySubscription: Subscription; currency: string; constructor( @@ -57,21 +54,13 @@ export class BlockFeesGraphComponent implements OnInit { private formBuilder: UntypedFormBuilder, private storageService: StorageService, private miningService: MiningService, - private stateService: StateService, private route: ActivatedRoute, private fiatShortenerPipe: FiatShortenerPipe, private fiatCurrencyPipe: FiatCurrencyPipe, ) { this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); this.radioGroupForm.controls.dateSpan.setValue('1y'); - - this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { - if (fiat && fiatCurrencies[fiat]?.indexed) { - this.currency = fiat; - } else { - this.currency = 'USD'; - } - }); + this.currency = 'USD'; } ngOnInit(): void { diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html index 54cd995aa..2a357843b 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -10,5 +10,6 @@ [cursorPosition]="tooltipPosition" [clickable]="!!selectedTx" [auditEnabled]="auditHighlighting" + [blockConversion]="blockConversion" > diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 7c71d36fe..b46f7a3e7 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -5,6 +5,7 @@ import BlockScene from './block-scene'; import TxSprite from './tx-sprite'; import TxView from './tx-view'; import { Position } from './sprite-types'; +import { Price } from 'src/app/services/price.service'; @Component({ selector: 'app-block-overview-graph', @@ -21,6 +22,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() mirrorTxid: string | void; @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; + @Input() blockConversion: Price; @Output() txClickEvent = new EventEmitter(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 826eaaf8f..2fa626a95 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -16,11 +16,11 @@ Amount - + Fee - {{ fee | number }} sat   + {{ fee | number }} sat   Fee rate diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts index 6702c4d62..1bd2b8714 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.ts @@ -1,6 +1,7 @@ import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { Position } from '../../components/block-overview-graph/sprite-types.js'; +import { Price } from 'src/app/services/price.service'; @Component({ selector: 'app-block-overview-tooltip', @@ -12,6 +13,7 @@ export class BlockOverviewTooltipComponent implements OnChanges { @Input() cursorPosition: Position; @Input() clickable: boolean; @Input() auditEnabled: boolean = false; + @Input() blockConversion: Price; txid = ''; fee = 0; diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts index ca1853633..2d8a6f858 100644 --- a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts @@ -1,19 +1,17 @@ import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; -import { Observable, Subscription } from 'rxjs'; +import { Observable } from 'rxjs'; import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { formatNumber } from '@angular/common'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; -import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '../../shared/graphs.utils'; +import { download, formatterXAxis } from '../../shared/graphs.utils'; import { MiningService } from '../../services/mining.service'; -import { StateService } from '../../services/state.service'; import { StorageService } from '../../services/storage.service'; import { ActivatedRoute } from '@angular/router'; import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe'; -import { fiatCurrencies } from '../../app.constants'; @Component({ selector: 'app-block-rewards-graph', @@ -47,7 +45,6 @@ export class BlockRewardsGraphComponent implements OnInit { timespan = ''; chartInstance: any = undefined; - currencySubscription: Subscription; currency: string; constructor( @@ -56,19 +53,12 @@ export class BlockRewardsGraphComponent implements OnInit { private apiService: ApiService, private formBuilder: UntypedFormBuilder, private miningService: MiningService, - private stateService: StateService, private storageService: StorageService, private route: ActivatedRoute, private fiatShortenerPipe: FiatShortenerPipe, private fiatCurrencyPipe: FiatCurrencyPipe, ) { - this.currencySubscription = this.stateService.fiatCurrency$.subscribe((fiat) => { - if (fiat && fiatCurrencies[fiat]?.indexed) { - this.currency = fiat; - } else { - this.currency = 'USD'; - } - }); + this.currency = 'USD'; } ngOnInit(): void { diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index bb4d2082c..4c7e4684a 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -108,6 +108,7 @@ [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" + [blockConversion]="blockConversion" (txClickEvent)="onTxClick($event)" > diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 9e476ac61..35f47de85 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -443,9 +443,9 @@ export class BlockComponent implements OnInit, OnDestroy { } this.priceSubscription = block$.pipe( switchMap((block) => { - return this.priceService.getPrices().pipe( - tap(() => { - this.blockConversion = this.priceService.getPriceForTimestamp(block.timestamp); + return this.priceService.getBlockPrice$(block.timestamp).pipe( + tap((price) => { + this.blockConversion = price; }) ); }) @@ -471,6 +471,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.auditSubscription?.unsubscribe(); this.unsubscribeNextBlockSubscriptions(); this.childChangeSubscription?.unsubscribe(); + this.priceSubscription?.unsubscribe(); } unsubscribeNextBlockSubscriptions() { diff --git a/frontend/src/app/components/qrcode/qrcode.component.html b/frontend/src/app/components/qrcode/qrcode.component.html index d7886b907..56f32f42c 100644 --- a/frontend/src/app/components/qrcode/qrcode.component.html +++ b/frontend/src/app/components/qrcode/qrcode.component.html @@ -1,4 +1,4 @@
- +
diff --git a/frontend/src/app/components/qrcode/qrcode.component.ts b/frontend/src/app/components/qrcode/qrcode.component.ts index e8ebac904..dad7522c6 100644 --- a/frontend/src/app/components/qrcode/qrcode.component.ts +++ b/frontend/src/app/components/qrcode/qrcode.component.ts @@ -12,6 +12,7 @@ export class QrcodeComponent implements AfterViewInit { @Input() data: string; @Input() size = 125; @Input() imageUrl: string; + @Input() border = 0; @ViewChild('canvas') canvas: ElementRef; qrcodeObject: any; diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index ff66e7b97..28f9cf6aa 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -123,7 +123,7 @@ export class StartComponent implements OnInit, OnDestroy { this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); if (firstVisibleBlock != null) { - this.scrollToBlock(firstVisibleBlock, offset); + this.scrollToBlock(firstVisibleBlock, offset + (this.isMobile ? this.blockWidth : 0)); } else { this.updatePages(); } @@ -178,8 +178,10 @@ export class StartComponent implements OnInit, OnDestroy { setTimeout(() => { this.scrollToBlock(height, blockOffset); }, 50); return; } - const targetHeight = this.isMobile ? height - 1 : height; - const viewingPageIndex = this.getPageIndexOf(targetHeight); + if (this.isMobile) { + blockOffset -= this.blockWidth; + } + const viewingPageIndex = this.getPageIndexOf(height); const pages = []; this.pageIndex = Math.max(viewingPageIndex - 1, 0); let viewingPage = this.getPageAt(viewingPageIndex); @@ -189,7 +191,7 @@ export class StartComponent implements OnInit, OnDestroy { viewingPage = this.getPageAt(viewingPageIndex); } const left = viewingPage.offset - this.getConvertedScrollOffset(); - const blockIndex = viewingPage.height - targetHeight; + const blockIndex = viewingPage.height - height; const targetOffset = (this.blockWidth * blockIndex) + left; let deltaOffset = targetOffset - blockOffset; diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 06a4c5836..4d036e131 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -327,9 +327,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fetchRbfHistory$.next(this.tx.txid); } - this.priceService.getPrices().pipe( - tap(() => { - this.blockConversion = this.priceService.getPriceForTimestamp(tx.status.block_time); + this.priceService.getBlockPrice$(tx.status.block_time, true).pipe( + tap((price) => { + this.blockConversion = price; }) ).subscribe(); diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index bfdaa02bc..6422d8507 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -6,7 +6,7 @@ import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.inter import { ElectrsApiService } from '../../services/electrs-api.service'; import { environment } from '../../../environments/environment'; import { AssetsService } from '../../services/assets.service'; -import { filter, map, tap, switchMap } from 'rxjs/operators'; +import { filter, map, tap, switchMap, shareReplay } from 'rxjs/operators'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { PriceService } from 'src/app/services/price.service'; @@ -150,10 +150,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { tx['addressValue'] = addressIn - addressOut; } - this.priceService.getPrices().pipe( - tap(() => { - tx['price'] = this.priceService.getPriceForTimestamp(tx.status.block_time); - }) + this.priceService.getBlockPrice$(tx.status.block_time).pipe( + tap((price) => tx['price'] = price) ).subscribe(); }); const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html index 25e9ccc1f..395c38f88 100644 --- a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html @@ -56,7 +56,7 @@

Confidential

-

+

diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts index 54c58ffab..da8d91ab3 100644 --- a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts @@ -1,5 +1,6 @@ -import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; -import { TransactionStripped } from '../../interfaces/websocket.interface'; +import { Component, ElementRef, ViewChild, Input, OnChanges, OnInit } from '@angular/core'; +import { tap } from 'rxjs'; +import { Price, PriceService } from 'src/app/services/price.service'; interface Xput { type: 'input' | 'output' | 'fee'; @@ -14,6 +15,7 @@ interface Xput { pegin?: boolean; pegout?: string; confidential?: boolean; + timestamp?: number; } @Component({ @@ -27,12 +29,21 @@ export class TxBowtieGraphTooltipComponent implements OnChanges { @Input() isConnector: boolean = false; tooltipPosition = { x: 0, y: 0 }; + blockConversion: Price; @ViewChild('tooltip') tooltipElement: ElementRef; - constructor() {} + constructor(private priceService: PriceService) {} ngOnChanges(changes): void { + if (changes.line?.currentValue) { + this.priceService.getBlockPrice$(changes.line?.currentValue.timestamp, true).pipe( + tap((price) => { + this.blockConversion = price; + }) + ).subscribe(); + } + if (changes.cursorPosition && changes.cursorPosition.currentValue) { let x = Math.max(10, changes.cursorPosition.currentValue.x - 50); let y = changes.cursorPosition.currentValue.y + 20; diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts index 49d97dd40..6be475243 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts @@ -29,6 +29,7 @@ interface Xput { pegin?: boolean; pegout?: string; confidential?: boolean; + timestamp?: number; } @Component({ @@ -152,6 +153,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { index: i, pegout: v?.pegout?.scriptpubkey_address, confidential: (this.isLiquid && v?.value === undefined), + timestamp: this.tx.status.block_time } as Xput; }); @@ -171,6 +173,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { coinbase: v?.is_coinbase, pegin: v?.is_pegin, confidential: (this.isLiquid && v?.prevout?.value === undefined), + timestamp: this.tx.status.block_time } as Xput; }); diff --git a/frontend/src/app/fiat/fiat.component.html b/frontend/src/app/fiat/fiat.component.html index a1bf79978..998153d29 100644 --- a/frontend/src/app/fiat/fiat.component.html +++ b/frontend/src/app/fiat/fiat.component.html @@ -1,7 +1,7 @@ {{ ( - (blockConversion.price[currency] > 0 ? blockConversion.price[currency] : null) ?? + (blockConversion.price[currency] >= 0 ? blockConversion.price[currency] : null) ?? (blockConversion.price['USD'] * blockConversion.exchangeRates['USD' + currency]) ?? 0 ) * value / 100000000 | fiatCurrency : digitsInfo : currency }} diff --git a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts index 20ce5cc6f..0ff9f4af1 100644 --- a/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts +++ b/frontend/src/app/lightning/nodes-networks-chart/nodes-networks-chart.component.ts @@ -130,7 +130,7 @@ export class NodesNetworksChartComponent implements OnInit { }, text: $localize`:@@b420668a91f8ebaf6e6409c4ba87f1d45961d2bd:Lightning Nodes Per Network`, left: 'center', - top: 11, + top: 0, zlevel: 10, }; } @@ -227,8 +227,8 @@ export class NodesNetworksChartComponent implements OnInit { title: title, animation: false, grid: { - height: this.widget ? 100 : undefined, - top: this.widget ? 10 : 40, + height: this.widget ? 90 : undefined, + top: this.widget ? 20 : 40, bottom: this.widget ? 0 : 70, right: (isMobile() && this.widget) ? 35 : this.right, left: (isMobile() && this.widget) ? 40 :this.left, diff --git a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts index 916483781..b93ee1f3d 100644 --- a/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts +++ b/frontend/src/app/lightning/statistics-chart/lightning-statistics-chart.component.ts @@ -121,7 +121,7 @@ export class LightningStatisticsChartComponent implements OnInit { }, text: $localize`:@@ea8db27e6db64f8b940711948c001a1100e5fe9f:Lightning Network Capacity`, left: 'center', - top: 11, + top: 0, zlevel: 10, }; } @@ -137,8 +137,8 @@ export class LightningStatisticsChartComponent implements OnInit { ]), ], grid: { - height: this.widget ? 100 : undefined, - top: this.widget ? 10 : 40, + height: this.widget ? 90 : undefined, + top: this.widget ? 20 : 40, bottom: this.widget ? 0 : 70, right: (isMobile() && this.widget) ? 35 : this.right, left: (isMobile() && this.widget) ? 40 :this.left, diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 2c74de361..840fd5070 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -305,7 +305,10 @@ export class ApiService { ); } - getHistoricalPrice$(): Observable { - return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + '/api/v1/historical-price'); + getHistoricalPrice$(timestamp: number | undefined): Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + '/api/v1/historical-price' + + (timestamp ? `?timestamp=${timestamp}` : '') + ); } } diff --git a/frontend/src/app/services/price.service.ts b/frontend/src/app/services/price.service.ts index fe6d67bb6..e3ec93c8b 100644 --- a/frontend/src/app/services/price.service.ts +++ b/frontend/src/app/services/price.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; -import { map, Observable, of, shareReplay } from 'rxjs'; +import { map, Observable, of, share, shareReplay, tap } from 'rxjs'; import { ApiService } from './api.service'; +import { StateService } from './state.service'; // nodejs backend interfaces export interface ApiPrice { @@ -40,13 +41,20 @@ export interface ConversionDict { providedIn: 'root' }) export class PriceService { + priceObservable$: Observable; + singlePriceObservable$: Observable; + + lastQueriedTimestamp: number; + lastPriceHistoryUpdate: number; + historicalPrice: ConversionDict = { prices: null, exchangeRates: null, }; constructor( - private apiService: ApiService + private apiService: ApiService, + private stateService: StateService ) { } @@ -61,65 +69,90 @@ export class PriceService { }; } - /** - * Fetch prices from the nodejs backend only once - */ - getPrices(): Observable { - if (this.historicalPrice.prices) { - return of(null); + getBlockPrice$(blockTimestamp: number, singlePrice = false): Observable { + if (this.stateService.env.BASE_MODULE !== 'mempool') { + return of(undefined); } - return this.apiService.getHistoricalPrice$().pipe( - map((conversion: Conversion) => { - if (!this.historicalPrice.prices) { - this.historicalPrice.prices = Object(); - } - for (const price of conversion.prices) { - this.historicalPrice.prices[price.time] = { - USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, - CHF: price.CHF, AUD: price.AUD, JPY: price.JPY - }; - } - this.historicalPrice.exchangeRates = conversion.exchangeRates; - return; - }), - shareReplay(), - ); - } + const now = new Date().getTime() / 1000; - /** - * Note: The first block with a price we have is block 68952 (using MtGox price history) - * - * @param blockTimestamp - */ - getPriceForTimestamp(blockTimestamp: number): Price | null { - if (!blockTimestamp) { - return undefined; - } - - const priceTimestamps = Object.keys(this.historicalPrice.prices); - priceTimestamps.push(Number.MAX_SAFE_INTEGER.toString()); - priceTimestamps.sort().reverse(); - - // Small trick here. Because latest blocks have higher timestamps than our - // latest price timestamp (we only insert once every hour), we have no price for them. - // Therefore we want to fallback to the websocket price by returning an undefined `price` field. - // Since this.historicalPrice.prices[Number.MAX_SAFE_INTEGER] does not exists - // it will return `undefined` and automatically use the websocket price. - // This way we can differenciate blocks without prices like the genesis block - // vs ones without a price (yet) like the latest blocks - - for (const t of priceTimestamps) { - const priceTimestamp = parseInt(t, 10); - if (blockTimestamp > priceTimestamp) { - return { - price: this.historicalPrice.prices[priceTimestamp], - exchangeRates: this.historicalPrice.exchangeRates, - }; + /** + * Query nearest price for a specific blockTimestamp. The observable is invalidated if we + * query a different timestamp than the last one + */ + if (singlePrice) { + if (!this.singlePriceObservable$ || (this.singlePriceObservable$ && blockTimestamp !== this.lastQueriedTimestamp)) { + this.singlePriceObservable$ = this.apiService.getHistoricalPrice$(blockTimestamp).pipe(shareReplay()); + this.lastQueriedTimestamp = blockTimestamp; } + + return this.singlePriceObservable$.pipe( + map((conversion) => { + if (conversion.prices.length <= 0) { + return this.getEmptyPrice(); + } + return { + price: { + USD: conversion.prices[0].USD, EUR: conversion.prices[0].EUR, GBP: conversion.prices[0].GBP, CAD: conversion.prices[0].CAD, + CHF: conversion.prices[0].CHF, AUD: conversion.prices[0].AUD, JPY: conversion.prices[0].JPY + }, + exchangeRates: conversion.exchangeRates, + }; + }) + ); } - return this.getEmptyPrice(); + /** + * Query all price history only once. The observable is invalidated after 1 hour + */ + else { + if (!this.priceObservable$ || (this.priceObservable$ && (now - this.lastPriceHistoryUpdate > 3600))) { + this.priceObservable$ = this.apiService.getHistoricalPrice$(undefined).pipe(shareReplay()); + this.lastPriceHistoryUpdate = new Date().getTime() / 1000; + } + + return this.priceObservable$.pipe( + map((conversion) => { + if (!blockTimestamp) { + return undefined; + } + + const historicalPrice = { + prices: {}, + exchangeRates: conversion.exchangeRates, + }; + for (const price of conversion.prices) { + historicalPrice.prices[price.time] = { + USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, + CHF: price.CHF, AUD: price.AUD, JPY: price.JPY + }; + } + + const priceTimestamps = Object.keys(historicalPrice.prices); + priceTimestamps.push(Number.MAX_SAFE_INTEGER.toString()); + priceTimestamps.sort().reverse(); + + // Small trick here. Because latest blocks have higher timestamps than our + // latest price timestamp (we only insert once every hour), we have no price for them. + // Therefore we want to fallback to the websocket price by returning an undefined `price` field. + // Since historicalPrice.prices[Number.MAX_SAFE_INTEGER] does not exists + // it will return `undefined` and automatically use the websocket price. + // This way we can differenciate blocks without prices like the genesis block + // vs ones without a price (yet) like the latest blocks + + for (const t of priceTimestamps) { + const priceTimestamp = parseInt(t, 10); + if (blockTimestamp > priceTimestamp) { + return { + price: historicalPrice.prices[priceTimestamp], + exchangeRates: historicalPrice.exchangeRates, + }; + } + } + + return this.getEmptyPrice(); + }) + ); + } } } -