diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 4e9a83d2c..93b614006 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -121,8 +121,10 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions) + .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/summary', this.getAddressTransactionSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash', this.getScriptHash) .get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs', this.getScriptHashTransactions) + .get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs/summary', this.getScriptHashTransactionSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix) ; } @@ -566,6 +568,13 @@ class BitcoinRoutes { } } + private async getAddressTransactionSummary(req: Request, res: Response): Promise { + if (config.MEMPOOL.BACKEND !== 'esplora') { + res.status(405).send('Address summary lookups require mempool/electrs backend.'); + return; + } + } + private async getScriptHash(req: Request, res: Response) { if (config.MEMPOOL.BACKEND === 'none') { res.status(405).send('Address lookups cannot be used with bitcoind as backend.'); @@ -609,6 +618,13 @@ class BitcoinRoutes { } } + private async getScriptHashTransactionSummary(req: Request, res: Response): Promise { + if (config.MEMPOOL.BACKEND !== 'esplora') { + res.status(405).send('Scripthash summary lookups require mempool/electrs backend.'); + return; + } + } + private async getAddressPrefix(req: Request, res: Response) { try { const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html new file mode 100644 index 000000000..fa7b29a99 --- /dev/null +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -0,0 +1,23 @@ + + +
+
+
+ Balance History +
+
+ + +
+
+
+
+
+
+ +
+

{{ error }}

+
+
+
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss new file mode 100644 index 000000000..d23b95d8d --- /dev/null +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -0,0 +1,75 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } + @media (min-width: 992px) { + height: 40px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + display: flex; + flex-direction: column; + padding: 0px; + width: 100%; + height: 400px; +} + +.error-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + + font-size: 15px; + color: grey; + font-weight: bold; +} + +.chart { + display: flex; + flex: 1; + width: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.disabled { + pointer-events: none; + opacity: 0.5; +} \ No newline at end of file diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts new file mode 100644 index 000000000..ab137e52b --- /dev/null +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -0,0 +1,183 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { echarts, EChartsOption } from '../../graphs/echarts'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { ChainStats } from '../../interfaces/electrs.interface'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; + +@Component({ + selector: 'app-address-graph', + templateUrl: './address-graph.component.html', + styleUrls: ['./address-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddressGraphComponent implements OnInit, OnChanges { + @Input() address: string; + @Input() isPubkey: boolean = false; + @Input() stats: ChainStats; + @Input() right: number | string = 10; + @Input() left: number | string = 70; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + error: any; + isLoading = true; + chartInstance: any = undefined; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private electrsApiService: ElectrsApiService, + private amountShortenerPipe: AmountShortenerPipe, + private cd: ChangeDetectorRef, + ) { + } + + ngOnInit(): void { + + } + + ngOnChanges(changes: SimpleChanges): void { + this.isLoading = true; + (this.isPubkey + ? this.electrsApiService.getScriptHashSummary$((this.address.length === 66 ? '21' : '41') + this.address + 'ac') + : this.electrsApiService.getAddressSummary$(this.address)).pipe( + catchError(e => { + this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; + return of(null); + }), + ).subscribe(addressSummary => { + if (addressSummary) { + this.error = null; + this.prepareChartOptions(addressSummary); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + prepareChartOptions(summary): void { + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); // + (summary[0]?.value || 0); + const data = summary.map(d => { + const balance = total; + total -= d.value; + return [d.time * 1000, balance, d]; + }).reverse(); + + const maxValue = data.reduce((acc, d) => Math.max(acc, Math.abs(d[1])), 0); + + this.chartOptions = { + color: [ + new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#FDD835' }, + { offset: 1, color: '#FB8C00' }, + ]), + ], + animation: false, + grid: { + top: 20, + bottom: 20, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: function (data): string { + const header = data.length === 1 + ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` + : `${data.length} transactions`; + const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + const val = data.reduce((total, d) => total + d.data[2].value, 0); + const color = val === 0 ? '' : (val > 0 ? '#1a9436' : '#dc3545'); + const symbol = val > 0 ? '+' : ''; + return ` +
+ ${header} +
+ ${symbol} ${(val / 100_000_000).toFixed(8)} BTC
+ ${(data[0].data[1] / 100_000_000).toFixed(8)} BTC +
+ ${date} +
+ `; + }.bind(this) + }, + xAxis: { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } + }, + yAxis: [ + { + type: 'value', + position: 'left', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val): string => { + if (maxValue > 100_000_000) { + return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; + } else if (maxValue > 10_000_000) { + return `${Math.round(val / 100_000_000)} BTC`; + } else if (maxValue > 100_000) { + return `${(val / 100_000_000).toFixed(2)} BTC`; + } else { + return `${this.amountShortenerPipe.transform(100_000_000, 0)} sats`; + } + } + }, + splitLine: { + show: false, + }, + }, + ], + series: [ + { + name: $localize`Balance:Balance`, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + data: data, + areaStyle: { + opacity: 0.5, + }, + type: 'line', + smooth: false, + step: 'end' + } + ], + }; + } + + onChartInit(ec) { + this.chartInstance = ec; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index ae2d7ba9c..41a9c3061 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -49,9 +49,19 @@ - + +
+
+
+
+ +
+
+
+
+

diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index c8c3d102d..761bd8e1f 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -32,12 +32,15 @@ import { AcceleratorDashboardComponent } from '../components/acceleration/accele import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-chart.component'; import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools/hashrate-chart-pools.component'; import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component'; +import { AddressComponent } from '../components/address/address.component'; +import { AddressGraphComponent } from '../components/address-graph/address-graph.component'; import { CommonModule } from '@angular/common'; @NgModule({ declarations: [ DashboardComponent, MempoolBlockComponent, + AddressComponent, MiningDashboardComponent, AcceleratorDashboardComponent, @@ -67,6 +70,7 @@ import { CommonModule } from '@angular/common'; HashrateChartComponent, HashrateChartPoolsComponent, BlockHealthGraphComponent, + AddressGraphComponent, ], imports: [ CommonModule, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index d3c7a1bce..e069022cd 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -19,6 +19,7 @@ import { TelevisionComponent } from '../components/television/television.compone import { DashboardComponent } from '../dashboard/dashboard.component'; import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; +import { AddressComponent } from '../components/address/address.component'; const routes: Routes = [ { @@ -67,6 +68,15 @@ const routes: Routes = [ }, ] }, + { + path: 'address/:id', + children: [], + component: AddressComponent, + data: { + ogImage: true, + networkSpecific: true, + } + }, { path: 'graphs', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 58a02ad79..3378ede76 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -149,6 +149,13 @@ export interface AddressOrScriptHash { mempool_stats: MempoolStats; } +export interface AddressTxSummary { + txid: string; + value: number; + height: number; + time: number; +} + export interface ChainStats { funded_txo_count: number; funded_txo_sum: number; diff --git a/frontend/src/app/liquid/liquid-master-page.module.ts b/frontend/src/app/liquid/liquid-master-page.module.ts index 4b8364ad5..5df9a5447 100644 --- a/frontend/src/app/liquid/liquid-master-page.module.ts +++ b/frontend/src/app/liquid/liquid-master-page.module.ts @@ -7,7 +7,6 @@ import { LiquidMasterPageComponent } from '../components/liquid-master-page/liqu import { StartComponent } from '../components/start/start.component'; -import { AddressComponent } from '../components/address/address.component'; import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; import { BlocksList } from '../components/blocks-list/blocks-list.component'; import { AssetGroupComponent } from '../components/assets/asset-group/asset-group.component'; @@ -51,15 +50,6 @@ const routes: Routes = [ path: 'trademark-policy', loadChildren: () => import('../components/trademark-policy/trademark-policy.module').then(m => m.TrademarkModule), }, - { - path: 'address/:id', - children: [], - component: AddressComponent, - data: { - ogImage: true, - networkSpecific: true, - } - }, { path: 'tx', component: StartComponent, diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index 4d23db92f..018809d59 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -5,8 +5,6 @@ import { MasterPageComponent } from './components/master-page/master-page.compon import { SharedModule } from './shared/shared.module'; import { StartComponent } from './components/start/start.component'; -import { AddressComponent } from './components/address/address.component'; -import { AddressGroupComponent } from './components/address-group/address-group.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { CalculatorComponent } from './components/calculator/calculator.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; @@ -56,15 +54,6 @@ const routes: Routes = [ path: 'trademark-policy', loadChildren: () => import('./components/trademark-policy/trademark-policy.module').then(m => m.TrademarkModule), }, - { - path: 'address/:id', - children: [], - component: AddressComponent, - data: { - ogImage: true, - networkSpecific: true, - } - }, { path: 'tx', component: StartComponent, diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index eaa1ab52d..748194a21 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { BehaviorSubject, Observable, catchError, filter, from, of, shareReplay, switchMap, take, tap } from 'rxjs'; -import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface'; +import { Transaction, Address, Outspend, Recent, Asset, ScriptHash, AddressTxSummary } from '../interfaces/electrs.interface'; import { StateService } from './state.service'; import { BlockExtended } from '../interfaces/node-api.interface'; import { calcScriptHash$ } from '../bitcoin.utils'; @@ -141,6 +141,14 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); } + getAddressSummary$(address: string, txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs/summary', { params }); + } + getScriptHashTransactions$(script: string, txid?: string): Observable { let params = new HttpParams(); if (txid) { @@ -151,6 +159,16 @@ export class ElectrsApiService { ); } + getScriptHashSummary$(script: string, txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return from(calcScriptHash$(script)).pipe( + switchMap(scriptHash => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/txs/summary', { params })), + ); + } + getAsset$(assetId: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 07ce39167..245b68c53 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -45,7 +45,6 @@ import { TransactionsListComponent } from '../components/transactions-list/trans import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { BlockFiltersComponent } from '../components/block-filters/block-filters.component'; -import { AddressComponent } from '../components/address/address.component'; import { AddressGroupComponent } from '../components/address-group/address-group.component'; import { SearchFormComponent } from '../components/search-form/search-form.component'; import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; @@ -147,7 +146,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BlockOverviewTooltipComponent, BlockFiltersComponent, TransactionsListComponent, - AddressComponent, AddressGroupComponent, SearchFormComponent, AddressLabelsComponent, @@ -276,7 +274,6 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BlockOverviewTooltipComponent, BlockFiltersComponent, TransactionsListComponent, - AddressComponent, AddressGroupComponent, SearchFormComponent, AddressLabelsComponent,