diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index aaeec6bab..f7f392068 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -41,7 +41,13 @@ class MiningRoutes { res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); - res.status(200).send(await PricesRepository.$getHistoricalPrice()); + 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/PricesRepository.ts b/backend/src/repositories/PricesRepository.ts index f460a504f..83336eaff 100644 --- a/backend/src/repositories/PricesRepository.ts +++ b/backend/src/repositories/PricesRepository.ts @@ -104,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`); } @@ -127,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/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 0c3a2b331..4d036e131 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -327,7 +327,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.fetchRbfHistory$.next(this.tx.txid); } - this.priceService.getBlockPrice$(tx.status.block_time).pipe( + this.priceService.getBlockPrice$(tx.status.block_time, true).pipe( tap((price) => { this.blockConversion = price; }) 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 a27885c1e..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 @@ -37,7 +37,7 @@ export class TxBowtieGraphTooltipComponent implements OnChanges { ngOnChanges(changes): void { if (changes.line?.currentValue) { - this.priceService.getBlockPrice$(changes.line?.currentValue.timestamp).pipe( + this.priceService.getBlockPrice$(changes.line?.currentValue.timestamp, true).pipe( tap((price) => { this.blockConversion = price; }) 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 409ff05fd..ef1a973a1 100644 --- a/frontend/src/app/services/price.service.ts +++ b/frontend/src/app/services/price.service.ts @@ -41,6 +41,10 @@ export interface ConversionDict { }) export class PriceService { priceObservable$: Observable; + singlePriceObservable$: Observable; + + lastQueriedTimestamp: number; + lastPriceHistoryUpdate: number; historicalPrice: ConversionDict = { prices: null, @@ -63,53 +67,83 @@ export class PriceService { }; } - getBlockPrice$(blockTimestamp: number): Observable { - if (!this.priceObservable$) { - this.priceObservable$ = this.apiService.getHistoricalPrice$().pipe(shareReplay()); + getBlockPrice$(blockTimestamp: number, singlePrice = false): Observable { + const now = new Date().getTime() / 1000; + + /** + * 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) => { + 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.priceObservable$.pipe( - map((conversion) => { - if (!blockTimestamp) { - return undefined; - } + /** + * 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; + } - 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 + return this.priceObservable$.pipe( + map((conversion) => { + if (!blockTimestamp) { + return undefined; + } + + const historicalPrice = { + prices: {}, + exchangeRates: conversion.exchangeRates, }; - } - - 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, + 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 }; } - } - - return this.getEmptyPrice(); - }) - ); + + 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(); + }) + ); + } } } -