From 3c94755a695dc2b93a3e2650fe1e4fe8a5679789 Mon Sep 17 00:00:00 2001 From: nymkappa <1612910616@pm.me> Date: Tue, 21 Feb 2023 12:36:43 +0900 Subject: [PATCH] Use historical price for older blocks and transactions --- backend/src/api/mining/mining-routes.ts | 11 +- backend/src/repositories/PricesRepository.ts | 53 ++++++++ frontend/src/app/app.module.ts | 2 + .../components/amount/amount.component.html | 16 ++- .../app/components/amount/amount.component.ts | 2 + .../app/components/block/block.component.html | 14 +- .../app/components/block/block.component.ts | 20 ++- .../transaction/transaction.component.html | 2 +- .../transaction/transaction.component.ts | 18 ++- .../transactions-list.component.html | 12 +- .../transactions-list.component.ts | 8 ++ frontend/src/app/fiat/fiat.component.html | 15 ++- frontend/src/app/fiat/fiat.component.ts | 2 + .../src/app/interfaces/electrs.interface.ts | 2 + frontend/src/app/services/api.service.ts | 5 + frontend/src/app/services/price.service.ts | 121 ++++++++++++++++++ 16 files changed, 284 insertions(+), 19 deletions(-) create mode 100644 frontend/src/app/services/price.service.ts diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 81c7b5a99..393ea119a 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -1,13 +1,13 @@ import { Application, Request, Response } from 'express'; import config from "../../config"; import logger from '../../logger'; -import audits from '../audit'; import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository'; import BlocksRepository from '../../repositories/BlocksRepository'; import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository'; import HashratesRepository from '../../repositories/HashratesRepository'; import bitcoinClient from '../bitcoin/bitcoin-client'; import mining from "./mining"; +import PricesRepository from '../../repositories/PricesRepository'; class MiningRoutes { public initRoutes(app: Application) { @@ -32,9 +32,18 @@ class MiningRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp) + .get(config.MEMPOOL.API_URL_PREFIX + 'historical-price', this.$getHistoricalPrice) ; } + private async $getHistoricalPrice(req: Request, res: Response): Promise { + try { + res.status(200).send(await PricesRepository.$getHistoricalPrice()); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async $getPool(req: Request, res: Response): Promise { try { const stats = await mining.$getPoolStat(req.params.slug); diff --git a/backend/src/repositories/PricesRepository.ts b/backend/src/repositories/PricesRepository.ts index bc606e68b..639f16dc6 100644 --- a/backend/src/repositories/PricesRepository.ts +++ b/backend/src/repositories/PricesRepository.ts @@ -3,6 +3,31 @@ import logger from '../logger'; import { IConversionRates } from '../mempool.interfaces'; import priceUpdater from '../tasks/price-updater'; +export interface ApiPrice { + time?: number, + USD: number, + EUR: number, + GBP: number, + CAD: number, + CHF: number, + AUD: number, + JPY: number, +} + +export interface ExchangeRates { + USDEUR: number, + USDGBP: number, + USDCAD: number, + USDCHF: number, + USDAUD: number, + USDJPY: number, +} + +export interface Conversion { + prices: ApiPrice[], + exchangeRates: ExchangeRates; +} + class PricesRepository { public async $savePrices(time: number, prices: IConversionRates): Promise { if (prices.USD === 0) { @@ -60,6 +85,34 @@ class PricesRepository { } return rates[0]; } + + public async $getHistoricalPrice(): 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`); + } + + // Compute fiat exchange rates + const latestPrice: ApiPrice = rates[0]; + 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 averaged historical prices from the db. Reason ${e instanceof Error ? e.message : e}`); + return null; + } + } } export default new PricesRepository(); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index f26b4a924..7afdba1f8 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -7,6 +7,7 @@ import { AppComponent } from './components/app/app.component'; import { ElectrsApiService } from './services/electrs-api.service'; import { StateService } from './services/state.service'; import { CacheService } from './services/cache.service'; +import { PriceService } from './services/price.service'; import { EnterpriseService } from './services/enterprise.service'; import { WebsocketService } from './services/websocket.service'; import { AudioService } from './services/audio.service'; @@ -26,6 +27,7 @@ const providers = [ ElectrsApiService, StateService, CacheService, + PriceService, WebsocketService, AudioService, SeoService, diff --git a/frontend/src/app/components/amount/amount.component.html b/frontend/src/app/components/amount/amount.component.html index 3526b554b..4a57e72e2 100644 --- a/frontend/src/app/components/amount/amount.component.html +++ b/frontend/src/app/components/amount/amount.component.html @@ -1,7 +1,19 @@ - {{ addPlus && satoshis >= 0 ? '+' : '' }}{{ (conversions ? conversions[currency] : 0) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency }} + + {{ addPlus && satoshis >= 0 ? '+' : '' }} + {{ + ( + (blockConversion.price[currency] > 0 ? blockConversion.price[currency] : null) ?? + (blockConversion.price['USD'] * blockConversion.exchangeRates['USD' + currency]) ?? 0 + ) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency + }} + + + {{ addPlus && satoshis >= 0 ? '+' : '' }}{{ (conversions ? conversions[currency] : 0) * satoshis / 100000000 | fiatCurrency : digitsInfo : currency }} + - + + Confidential diff --git a/frontend/src/app/components/amount/amount.component.ts b/frontend/src/app/components/amount/amount.component.ts index cfdc50468..927504012 100644 --- a/frontend/src/app/components/amount/amount.component.ts +++ b/frontend/src/app/components/amount/amount.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; import { Observable, Subscription } from 'rxjs'; +import { Price } from 'src/app/services/price.service'; @Component({ selector: 'app-amount', @@ -21,6 +22,7 @@ export class AmountComponent implements OnInit, OnDestroy { @Input() digitsInfo = '1.8-8'; @Input() noFiat = false; @Input() addPlus = false; + @Input() blockConversion: Price; constructor( private stateService: StateService, diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 0fda470d6..bb4d2082c 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -124,7 +124,13 @@ Median fee - ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB + ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB + + + + @@ -132,13 +138,13 @@ - +   + [blockConversion]="blockConversion" [value]="fees * 100000000" digitsInfo="1.2-2"> @@ -147,7 +153,7 @@ - + diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index ba5dd8cf7..9e476ac61 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -13,6 +13,7 @@ import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces import { ApiService } from '../../services/api.service'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { detectWebGL } from '../../shared/graphs.utils'; +import { PriceService, Price } from 'src/app/services/price.service'; @Component({ selector: 'app-block', @@ -81,6 +82,9 @@ export class BlockComponent implements OnInit, OnDestroy { timeLtr: boolean; childChangeSubscription: Subscription; auditPrefSubscription: Subscription; + + priceSubscription: Subscription; + blockConversion: Price; @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; @ViewChildren('blockGraphActual') blockGraphActual: QueryList; @@ -94,7 +98,8 @@ export class BlockComponent implements OnInit, OnDestroy { private seoService: SeoService, private websocketService: WebsocketService, private relativeUrlPipe: RelativeUrlPipe, - private apiService: ApiService + private apiService: ApiService, + private priceService: PriceService, ) { this.webGlEnabled = detectWebGL(); } @@ -432,6 +437,19 @@ export class BlockComponent implements OnInit, OnDestroy { } } }); + + if (this.priceSubscription) { + this.priceSubscription.unsubscribe(); + } + this.priceSubscription = block$.pipe( + switchMap((block) => { + return this.priceService.getPrices().pipe( + tap(() => { + this.blockConversion = this.priceService.getPriceForTimestamp(block.timestamp); + }) + ); + }) + ).subscribe(); } ngAfterViewInit(): void { diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index f0b25d60e..07234f7bc 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -469,7 +469,7 @@ Fee - {{ tx.fee | number }} sat + {{ tx.fee | number }} sat Fee rate diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 7e1ae525e..06a4c5836 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -8,10 +8,11 @@ import { retryWhen, delay, map, - mergeMap + mergeMap, + tap } from 'rxjs/operators'; import { Transaction } from '../../interfaces/electrs.interface'; -import { of, merge, Subscription, Observable, Subject, timer, combineLatest, from, throwError } from 'rxjs'; +import { of, merge, Subscription, Observable, Subject, timer, from, throwError } from 'rxjs'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; @@ -21,6 +22,7 @@ import { SeoService } from '../../services/seo.service'; import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface'; import { LiquidUnblinding } from './liquid-ublinding'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { Price, PriceService } from 'src/app/services/price.service'; @Component({ selector: 'app-transaction', @@ -69,7 +71,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { hideFlow: boolean = this.stateService.hideFlow.value; overrideFlowPreference: boolean = null; flowEnabled: boolean; - + blockConversion: Price; tooltipPosition: { x: number, y: number }; @ViewChild('graphContainer') @@ -85,7 +87,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private websocketService: WebsocketService, private audioService: AudioService, private apiService: ApiService, - private seoService: SeoService + private seoService: SeoService, + private priceService: PriceService, ) {} ngOnInit() { @@ -323,6 +326,13 @@ 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); + }) + ).subscribe(); + setTimeout(() => { this.applyFragment(); }, 0); }, (error) => { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 7602e40cc..e5280d572 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -88,7 +88,7 @@ - + @@ -216,7 +216,7 @@ - + @@ -283,7 +283,9 @@
- {{ tx.fee / (tx.weight / 4) | feeRounding }} sat/vB  – {{ tx.fee | number }} sat + {{ tx.fee / (tx.weight / 4) | feeRounding }} sat/vB  – {{ tx.fee | number }} sat
Show more inputs to reveal fee data
@@ -301,12 +303,12 @@
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 67df2daa2..bfdaa02bc 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -9,6 +9,7 @@ import { AssetsService } from '../../services/assets.service'; import { filter, map, tap, switchMap } from 'rxjs/operators'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; +import { PriceService } from 'src/app/services/price.service'; @Component({ selector: 'app-transactions-list', @@ -50,6 +51,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { private apiService: ApiService, private assetsService: AssetsService, private ref: ChangeDetectorRef, + private priceService: PriceService, ) { } ngOnInit(): void { @@ -147,6 +149,12 @@ export class TransactionsListComponent implements OnInit, OnChanges { tx['addressValue'] = addressIn - addressOut; } + + this.priceService.getPrices().pipe( + tap(() => { + tx['price'] = this.priceService.getPriceForTimestamp(tx.status.block_time); + }) + ).subscribe(); }); const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); if (txIds.length) { diff --git a/frontend/src/app/fiat/fiat.component.html b/frontend/src/app/fiat/fiat.component.html index 99a177cc0..a1bf79978 100644 --- a/frontend/src/app/fiat/fiat.component.html +++ b/frontend/src/app/fiat/fiat.component.html @@ -1 +1,14 @@ -{{ (conversions ? conversions[currency] : 0) * value / 100000000 | fiatCurrency : digitsInfo : currency }} \ No newline at end of file + + {{ + ( + (blockConversion.price[currency] > 0 ? blockConversion.price[currency] : null) ?? + (blockConversion.price['USD'] * blockConversion.exchangeRates['USD' + currency]) ?? 0 + ) * value / 100000000 | fiatCurrency : digitsInfo : currency + }} + + + + + {{ (conversions[currency] ?? conversions['USD'] ?? 0) * value / 100000000 | fiatCurrency : digitsInfo : currency }} + + \ No newline at end of file diff --git a/frontend/src/app/fiat/fiat.component.ts b/frontend/src/app/fiat/fiat.component.ts index bc0f6a0de..909b249c0 100644 --- a/frontend/src/app/fiat/fiat.component.ts +++ b/frontend/src/app/fiat/fiat.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; +import { Price } from '../services/price.service'; import { StateService } from '../services/state.service'; @Component({ @@ -15,6 +16,7 @@ export class FiatComponent implements OnInit, OnDestroy { @Input() value: number; @Input() digitsInfo = '1.2-2'; + @Input() blockConversion: Price; constructor( private stateService: StateService, diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 63dec7abd..dcccfb67c 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -1,3 +1,4 @@ +import { Price } from '../services/price.service'; import { IChannel } from './node-api.interface'; export interface Transaction { @@ -23,6 +24,7 @@ export interface Transaction { _deduced?: boolean; _outspends?: Outspend[]; _channels?: TransactionChannels; + price?: Price; } export interface TransactionChannels { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 04b2b72e2..2c74de361 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { StateService } from './state.service'; import { WebsocketResponse } from '../interfaces/websocket.interface'; import { Outspend, Transaction } from '../interfaces/electrs.interface'; +import { Conversion } from './price.service'; @Injectable({ providedIn: 'root' @@ -303,4 +304,8 @@ export class ApiService { (style !== undefined ? `?style=${style}` : '') ); } + + getHistoricalPrice$(): Observable { + return this.httpClient.get( this.apiBaseUrl + this.apiBasePath + '/api/v1/historical-price'); + } } diff --git a/frontend/src/app/services/price.service.ts b/frontend/src/app/services/price.service.ts new file mode 100644 index 000000000..3320280e9 --- /dev/null +++ b/frontend/src/app/services/price.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@angular/core'; +import { map, Observable, of, shareReplay } from 'rxjs'; +import { ApiService } from './api.service'; + +// nodejs backend interfaces +export interface ApiPrice { + time?: number, + USD: number, + EUR: number, + GBP: number, + CAD: number, + CHF: number, + AUD: number, + JPY: number, +} +export interface ExchangeRates { + USDEUR: number, + USDGBP: number, + USDCAD: number, + USDCHF: number, + USDAUD: number, + USDJPY: number, +} +export interface Conversion { + prices: ApiPrice[], + exchangeRates: ExchangeRates; +} + +// frontend interface +export interface Price { + price: ApiPrice, + exchangeRates: ExchangeRates, +} +export interface ConversionDict { + prices: { [timestamp: number]: ApiPrice } + exchangeRates: ExchangeRates; +} + +@Injectable({ + providedIn: 'root' +}) +export class PriceService { + historicalPrice: ConversionDict = { + prices: null, + exchangeRates: null, + }; + + constructor( + private apiService: ApiService + ) { + } + + getEmptyPrice(): Price { + return { + price: { + USD: 0, EUR: 0, GBP: 0, CAD: 0, CHF: 0, AUD: 0, JPY: 0, + }, + exchangeRates: { + USDEUR: 0, USDGBP: 0, USDCAD: 0, USDCHF: 0, USDAUD: 0, USDJPY: 0, + }, + }; + } + + /** + * Fetch prices from the nodejs backend only once + */ + getPrices(): Observable { + if (this.historicalPrice.prices) { + return of(null); + } + + 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(), + ); + } + + /** + * Note: The first block with a price we have is block 68952 (using MtGox price history) + * + * @param blockTimestamp + */ + getPriceForTimestamp(blockTimestamp: number): Price | null { + 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, + }; + } + } + + return this.getEmptyPrice(); + } +} +