From a7ba4a0be8ebe9af3d2e26e77b30116bbe7cc954 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 28 Mar 2024 08:27:57 +0000 Subject: [PATCH 1/6] Add multi-address wallet page --- frontend/src/app/app-routing.module.ts | 10 +- .../components/address/address.component.html | 2 +- .../addresses-treemap.component.html | 10 + .../addresses-treemap.component.scss | 17 + .../addresses-treemap.component.ts | 145 +++++++ .../transactions-list.component.html | 6 +- .../transactions-list.component.ts | 90 +++-- .../components/wallet/wallet.component.html | 183 +++++++++ .../components/wallet/wallet.component.scss | 117 ++++++ .../app/components/wallet/wallet.component.ts | 360 ++++++++++++++++++ frontend/src/app/graphs/graphs.module.ts | 4 + .../src/app/graphs/graphs.routing.module.ts | 10 + .../src/app/services/electrs-api.service.ts | 36 ++ 13 files changed, 939 insertions(+), 51 deletions(-) create mode 100644 frontend/src/app/components/addresses-treemap/addresses-treemap.component.html create mode 100644 frontend/src/app/components/addresses-treemap/addresses-treemap.component.scss create mode 100644 frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts create mode 100644 frontend/src/app/components/wallet/wallet.component.html create mode 100644 frontend/src/app/components/wallet/wallet.component.scss create mode 100644 frontend/src/app/components/wallet/wallet.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 1f2e3f531..f385b7c20 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -31,7 +31,7 @@ let routes: Routes = [ data: { preload: true }, }, { - path: 'wallet', + path: 'widget/wallet', children: [], component: AddressGroupComponent, data: { @@ -112,7 +112,7 @@ let routes: Routes = [ data: { preload: true }, }, { - path: 'wallet', + path: 'widget/wallet', children: [], component: AddressGroupComponent, data: { @@ -153,7 +153,7 @@ let routes: Routes = [ data: { preload: true }, }, { - path: 'wallet', + path: 'widget/wallet', children: [], component: AddressGroupComponent, data: { @@ -234,7 +234,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { data: { preload: true }, }, { - path: 'wallet', + path: 'widget/wallet', children: [], component: AddressGroupComponent, data: { @@ -269,7 +269,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { data: { preload: true }, }, { - path: 'wallet', + path: 'widget/wallet', children: [], component: AddressGroupComponent, data: { diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index b893d7e22..41d8c151f 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -117,7 +117,7 @@ - +
diff --git a/frontend/src/app/components/addresses-treemap/addresses-treemap.component.html b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.html new file mode 100644 index 000000000..1c44f9aa3 --- /dev/null +++ b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.html @@ -0,0 +1,10 @@ +
+
+
+
+
+ +
+
+
+
diff --git a/frontend/src/app/components/addresses-treemap/addresses-treemap.component.scss b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.scss new file mode 100644 index 000000000..78510203f --- /dev/null +++ b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.scss @@ -0,0 +1,17 @@ +.node-channels-container { + position: relative; +} + +.loading-spinner { + position: absolute; + top: 0; + left: 0; + right: 0; + width: 100%; + z-index: 100; +} + +.spinner-border { + position: relative; + top: 225px; +} \ No newline at end of file diff --git a/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts new file mode 100644 index 000000000..705941caf --- /dev/null +++ b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts @@ -0,0 +1,145 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges } from '@angular/core'; +import { Router } from '@angular/router'; +import { EChartsOption, TreemapSeriesOption } from '../../graphs/echarts'; +import { lerpColor } from '../../shared/graphs.utils'; +import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; +import { LightningApiService } from '../../lightning/lightning-api.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '../../services/state.service'; +import { Address } from '../../interfaces/electrs.interface'; +import { formatNumber } from '@angular/common'; + +@Component({ + selector: 'app-addresses-treemap', + templateUrl: './addresses-treemap.component.html', + styleUrls: ['./addresses-treemap.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddressesTreemap implements OnChanges { + @Input() addresses: Address[]; + @Input() isLoading: boolean = false; + + chartInstance: any; + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private lightningApiService: LightningApiService, + private amountShortenerPipe: AmountShortenerPipe, + private zone: NgZone, + private router: Router, + public stateService: StateService, + ) {} + + ngOnChanges(): void { + this.prepareChartOptions(); + } + + prepareChartOptions(): void { + const maxTxs = this.addresses.reduce((max, address) => Math.max(max, address.chain_stats.tx_count), 0); + const data = this.addresses.map(address => ({ + address: address.address, + value: address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum, + stats: address.chain_stats, + itemStyle: { + color: lerpColor('#1E88E5', '#D81B60', address.chain_stats.tx_count / maxTxs), + } + })); + this.chartOptions = { + tooltip: { + trigger: 'item', + textStyle: { + align: 'left', + } + }, + series: [ + { + height: 300, + left: 0, + right: 0, + bottom: 0, + top: 0, + roam: false, + type: 'treemap', + data: data, + nodeClick: 'link', + progressive: 100, + tooltip: { + show: true, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: (value): string => { + if (!value.data.address) { + return ''; + } + return ` + + + + + + + + + + + + + + + + + + + + + + +
${value.data.address}
Recieved${this.formatValue(value.data.stats.funded_txo_sum)}
Sent${this.formatValue(value.data.stats.spent_txo_sum)}
Balance${this.formatValue(value.data.stats.funded_txo_sum - value.data.stats.spent_txo_sum)}
Transaction count${value.data.stats.tx_count}
+ `; + } + }, + itemStyle: { + borderColor: 'black', + borderWidth: 1, + }, + breadcrumb: { + show: false, + } + } + ] + }; + } + + formatValue(sats: number): string { + if (sats > 100000000) { + return formatNumber(sats / 100000000, this.locale, '1.2-2') + ' BTC'; + } else { + return this.amountShortenerPipe.transform(sats, 2) + ' sats'; + } + } + + onChartInit(ec: any): void { + this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + //@ts-ignore + if (!e.data.address) { + return; + } + this.zone.run(() => { + //@ts-ignore + const url = new RelativeUrlPipe(this.stateService).transform(`/address/${e.data.address}`); + this.router.navigate([url]); + }); + }); + } +} \ No newline at end of file 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 217eab7d7..bc38e0dd3 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -23,7 +23,7 @@ @@ -214,7 +214,7 @@ @@ -353,7 +353,7 @@ - + +
+ + + + + +
+
+
+ + + + + + + + + + + + +
+
+
+
+ +
+
+
+ +
+ + +
+ +
+ Error loading wallet data. +
+ + There many transactions in this wallet, more than your backend can handle. See more on
setting up a stronger backend. +

+ Consider viewing this wallet on the official Mempool website instead: + +
+ https://mempool.space/wallet?addresses={{ addressStrings.join(',') }} +
+ http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/wallet?addresses={{ addressStrings.join(',') }} +

+ ({{ error | httpErrorMsg }}) +
+ + + + Error loading wallet data. + + + + + + +
+ + + Confidential + + + +
+ +
+
diff --git a/frontend/src/app/components/wallet/wallet.component.scss b/frontend/src/app/components/wallet/wallet.component.scss new file mode 100644 index 000000000..6723cffbc --- /dev/null +++ b/frontend/src/app/components/wallet/wallet.component.scss @@ -0,0 +1,117 @@ +.qr-wrapper { + background-color: #FFF; + padding: 10px; + padding-bottom: 5px; + display: inline-block; +} + +.treemap-col { + width: 45%; + height: 300px; +} + +.fiat { + display: block; + @media (min-width: 992px){ + display: inline-block; + margin-left: 10px; + } +} + +.table { + tr td { + &:last-child { + text-align: right; + @media (min-width: 576px) { + text-align: left; + } + } + } +} + +.address-list { + width: 100%; + max-width: 200px; +} + +h1 { + margin: 0px; + padding: 0px; + margin-right: 10px; + font-size: 1.9rem; + @media (min-width: 576px) { + font-size: 2rem; + float: left; + } + @media (min-width: 768px) { + font-size: 2.5rem; + } +} + +.title-address { + align-items: baseline; +} + +.address-link { + line-height: 56px; + margin-left: 0px; + top: -2px; + position: relative; + @media (min-width: 768px) { + line-height: 69px; + } +} + +.row{ + flex-direction: column; + @media (min-width: 576px) { + flex-direction: row; + } +} + +@media (max-width: 767.98px) { + .mobile-bottomcol { + margin-top: 15px; + } + .details-table td:first-child { + white-space: pre-wrap; + } +} + +.tx-link { + display: block; + height: 100%; + top: 9px; + position: relative; + @media (min-width: 576px) { + top: 11px; + } + @media (min-width: 768px) { + max-width: calc(100% - 180px); + top: 17px; + } +} + +.title-tx { + h2 { + line-height: 1; + margin-bottom: 10px; + } +} + +.liquid-address { + .address-table { + table-layout: fixed; + + tr td:first-child { + width: 170px; + } + tr td:last-child { + width: 80%; + } + } + + .qrcode-col { + flex-grow: 0.5; + } +} diff --git a/frontend/src/app/components/wallet/wallet.component.ts b/frontend/src/app/components/wallet/wallet.component.ts new file mode 100644 index 000000000..e91def889 --- /dev/null +++ b/frontend/src/app/components/wallet/wallet.component.ts @@ -0,0 +1,360 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { switchMap, filter, catchError, map, tap, share } from 'rxjs/operators'; +import { Address, Transaction } from '../../interfaces/electrs.interface'; +import { WebsocketService } from '../../services/websocket.service'; +import { StateService } from '../../services/state.service'; +import { AudioService } from '../../services/audio.service'; +import { ApiService } from '../../services/api.service'; +import { of, merge, Subscription, Observable, combineLatest, forkJoin } from 'rxjs'; +import { SeoService } from '../../services/seo.service'; +import { seoDescriptionNetwork } from '../../shared/common.utils'; + +@Component({ + selector: 'app-wallet', + templateUrl: './wallet.component.html', + styleUrls: ['./wallet.component.scss'] +}) +export class WalletComponent implements OnInit, OnDestroy { + network = ''; + + addresses: Address[]; + addressStrings: string[]; + isLoadingAddress = true; + transactions: Transaction[]; + isLoadingTransactions = true; + retryLoadMore = false; + error: any; + mainSubscription: Subscription; + wsSubscription: Subscription; + addressLoadingStatus$: Observable; + + collapseAddresses: boolean = true; + + fullyLoaded = false; + txCount = 0; + received = 0; + sent = 0; + chainBalance = 0; + + private tempTransactions: Transaction[]; + private timeTxIndexes: number[]; + private lastTransactionTxId: string; + + constructor( + private route: ActivatedRoute, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private stateService: StateService, + private audioService: AudioService, + private apiService: ApiService, + private seoService: SeoService, + ) { } + + ngOnInit(): void { + this.stateService.networkChanged$.subscribe((network) => this.network = network); + this.websocketService.want(['blocks']); + + const addresses$ = this.route.queryParamMap.pipe( + map((queryParams) => (queryParams.get('addresses') as string)?.split(',').map(this.normalizeAddress)), + tap(addresses => { + this.addressStrings = addresses; + this.error = undefined; + this.isLoadingAddress = true; + this.fullyLoaded = false; + this.addresses = []; + this.isLoadingTransactions = true; + this.transactions = null; + document.body.scrollTo(0, 0); + const titleLabel = addresses[0] + (addresses.length > 1 ? ` +${addresses.length - 1} addresses` : ''); + this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${titleLabel}}:INTERPOLATION:`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${titleLabel}:INTERPOLATION:.`); + }), + share() + ); + + this.addressLoadingStatus$ = addresses$ + .pipe( + switchMap(() => this.stateService.loadingIndicators$), + map((indicators) => indicators['address-' + this.addressStrings.join(',')] !== undefined ? indicators['address-' + this.addressStrings.join(',')] : 0) + ); + + this.mainSubscription = combineLatest([ + addresses$, + merge( + of(true), + this.stateService.connectionState$.pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)), + ), + ]).pipe( + switchMap(([addresses]) => { + return forkJoin( + addresses.map((address) => + address.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) + ? this.electrsApiService.getPubKeyAddress$(address) + : this.electrsApiService.getAddress$(address) + ) + ); + }), + tap((addresses: Address[]) => { + this.addresses = addresses; + this.updateChainStats(); + this.isLoadingAddress = false; + this.isLoadingTransactions = true; + this.websocketService.startTrackAddresses(addresses.map(address => address.address)); + }), + switchMap((addresses) => { + return addresses[0].is_pubkey + ? this.electrsApiService.getScriptHashesTransactions$(addresses.map(address => (address.address.length === 66 ? '21' : '41') + address.address + 'ac')) + : this.electrsApiService.getAddressesTransactions$(addresses.map(address => address.address)); + }), + switchMap((transactions) => { + this.tempTransactions = transactions; + if (transactions.length) { + this.lastTransactionTxId = transactions[transactions.length - 1].txid; + } + + const fetchTxs: string[] = []; + this.timeTxIndexes = []; + transactions.forEach((tx, index) => { + if (!tx.status.confirmed) { + fetchTxs.push(tx.txid); + this.timeTxIndexes.push(index); + } + }); + if (!fetchTxs.length) { + return of([]); + } + return this.apiService.getTransactionTimes$(fetchTxs).pipe( + catchError((err) => { + this.isLoadingAddress = false; + this.isLoadingTransactions = false; + this.error = err; + this.seoService.logSoft404(); + console.log(err); + return of([]); + }) + ); + }) + ) + .subscribe((times: number[] | null) => { + if (!times) { + return; + } + times.forEach((time, index) => { + this.tempTransactions[this.timeTxIndexes[index]].firstSeen = time; + }); + this.tempTransactions.sort((a, b) => { + if (b.status.confirmed) { + if (b.status.block_height === a.status.block_height) { + return b.status.block_time - a.status.block_time; + } + return b.status.block_height - a.status.block_height; + } + return b.firstSeen - a.firstSeen; + }); + + this.transactions = this.tempTransactions; + this.isLoadingTransactions = false; + }, + (error) => { + console.log(error); + this.error = error; + this.seoService.logSoft404(); + this.isLoadingAddress = false; + }); + + this.wsSubscription = this.stateService.multiAddressTransactions$.subscribe(update => { + for (const address of Object.keys(update)) { + for (const transaction of update[address].mempool) { + this.addTransaction(transaction); + } + for (const transaction of update[address].confirmed) { + const tx = this.transactions.find((t) => t.txid === transaction.txid); + if (tx) { + this.removeTransaction(tx); + tx.status = transaction.status; + this.transactions = this.transactions.slice(); + this.audioService.playSound('magic'); + } else { + if (this.addTransaction(transaction, false)) { + this.audioService.playSound('magic'); + } + } + } + for (const transaction of update[address].removed) { + this.removeTransaction(transaction); + } + } + }); + } + + addTransaction(transaction: Transaction, playSound: boolean = true): boolean { + if (this.transactions.some((t) => t.txid === transaction.txid)) { + return false; + } + + this.transactions.unshift(transaction); + this.transactions = this.transactions.slice(); + this.txCount++; + + if (playSound) { + if (transaction.vout.some((vout) => this.addressStrings.includes(vout?.scriptpubkey_address))) { + this.audioService.playSound('cha-ching'); + } else { + this.audioService.playSound('chime'); + } + } + + for (const address of this.addresses) { + let match = false; + transaction.vin.forEach((vin) => { + if (vin?.prevout?.scriptpubkey_address === address.address) { + match = true; + this.sent += vin.prevout.value; + if (transaction.status?.confirmed) { + address.chain_stats.funded_txo_count++; + address.chain_stats.funded_txo_sum += vin.prevout.value; + } else { + address.mempool_stats.funded_txo_count++; + address.mempool_stats.funded_txo_sum += vin.prevout.value; + } + } + }); + transaction.vout.forEach((vout) => { + match = true; + if (vout?.scriptpubkey_address === address.address) { + this.received += vout.value; + } + if (transaction.status?.confirmed) { + address.chain_stats.spent_txo_count++; + address.chain_stats.spent_txo_sum += vout.value; + } else { + address.mempool_stats.spent_txo_count++; + address.mempool_stats.spent_txo_sum += vout.value; + } + }); + if (match) { + if (transaction.status?.confirmed) { + address.chain_stats.tx_count++; + } else { + address.mempool_stats.tx_count++; + } + } + } + + return true; + } + + removeTransaction(transaction: Transaction): boolean { + const index = this.transactions.findIndex(((tx) => tx.txid === transaction.txid)); + if (index === -1) { + return false; + } + + this.transactions.splice(index, 1); + this.transactions = this.transactions.slice(); + this.txCount--; + + for (const address of this.addresses) { + let match = false; + transaction.vin.forEach((vin) => { + if (vin?.prevout?.scriptpubkey_address === address.address) { + match = true; + this.sent -= vin.prevout.value; + if (transaction.status?.confirmed) { + address.chain_stats.funded_txo_count--; + address.chain_stats.funded_txo_sum -= vin.prevout.value; + } else { + address.mempool_stats.funded_txo_count--; + address.mempool_stats.funded_txo_sum -= vin.prevout.value; + } + } + }); + transaction.vout.forEach((vout) => { + match = true; + if (vout?.scriptpubkey_address === address.address) { + this.received -= vout.value; + } + if (transaction.status?.confirmed) { + address.chain_stats.spent_txo_count--; + address.chain_stats.spent_txo_sum -= vout.value; + } else { + address.mempool_stats.spent_txo_count--; + address.mempool_stats.spent_txo_sum -= vout.value; + } + }); + if (match) { + if (transaction.status?.confirmed) { + address.chain_stats.tx_count--; + } else { + address.mempool_stats.tx_count--; + } + } + } + + return true; + } + + loadMore(): void { + if (this.isLoadingTransactions || this.fullyLoaded) { + return; + } + this.isLoadingTransactions = true; + this.retryLoadMore = false; + + (this.addresses[0].is_pubkey + ? this.electrsApiService.getScriptHashesTransactions$(this.addresses.map(address => (address.address.length === 66 ? '21' : '41') + address.address + 'ac'), this.lastTransactionTxId) + : this.electrsApiService.getAddressesTransactions$(this.addresses.map(address => address.address), this.lastTransactionTxId) + ).pipe( + catchError((error) => { + this.isLoadingTransactions = false; + this.retryLoadMore = true; + // In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page. + if (error.status === 422) { + window.location.reload(); + } + return of([]); + }) + ).subscribe((transactions: Transaction[]) => { + if (transactions && transactions.length) { + this.lastTransactionTxId = transactions[transactions.length - 1].txid; + this.transactions = this.transactions.concat(transactions); + } else { + this.fullyLoaded = true; + } + this.isLoadingTransactions = false; + }); + } + + normalizeAddress(address: string): string { + if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) { + return address.toLowerCase(); + } else { + return address; + } + } + + updateChainStats(): void { + let received = 0; + let sent = 0; + let txCount = 0; + let chainBalance = 0; + for (const address of this.addresses) { + received += address.chain_stats.funded_txo_sum + address.mempool_stats.funded_txo_sum; + sent += address.chain_stats.spent_txo_sum + address.mempool_stats.spent_txo_sum; + txCount += address.chain_stats.tx_count + address.mempool_stats.tx_count; + chainBalance += (address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum); + } + this.received = received; + this.sent = sent; + this.txCount = txCount; + this.chainBalance = chainBalance; + } + + ngOnDestroy(): void { + this.mainSubscription.unsubscribe(); + this.websocketService.stopTrackingAddresses(); + this.wsSubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index ee51069c5..10f0b4d6c 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -35,9 +35,11 @@ import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-ch 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 { WalletComponent } from '../components/wallet/wallet.component'; import { AddressGraphComponent } from '../components/address-graph/address-graph.component'; import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component'; import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component'; +import { AddressesTreemap } from '../components/addresses-treemap/addresses-treemap.component'; import { CommonModule } from '@angular/common'; @NgModule({ @@ -46,6 +48,7 @@ import { CommonModule } from '@angular/common'; CustomDashboardComponent, MempoolBlockComponent, AddressComponent, + WalletComponent, MiningDashboardComponent, AcceleratorDashboardComponent, @@ -79,6 +82,7 @@ import { CommonModule } from '@angular/common'; AddressGraphComponent, UtxoGraphComponent, ActiveAccelerationBox, + AddressesTreemap, ], imports: [ CommonModule, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index 40bf64144..5e7707a89 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -22,6 +22,7 @@ import { CustomDashboardComponent } from '../components/custom-dashboard/custom- 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'; +import { WalletComponent } from '../components/wallet/wallet.component'; const browserWindow = window || {}; // @ts-ignore @@ -88,6 +89,15 @@ const routes: Routes = [ networkSpecific: true, } }, + { + path: 'wallet', + children: [], + component: WalletComponent, + data: { + ogImage: true, + networkSpecific: true, + } + }, { path: 'graphs', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index f1468f8aa..2ae9138b3 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -142,6 +142,14 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); } + getAddressesTransactions$(addresses: 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/addresses/txs?addresses=${addresses.join(',')}`, { params }); + } + getAddressSummary$(address: string, txid?: string): Observable { let params = new HttpParams(); if (txid) { @@ -150,6 +158,14 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs/summary', { params }); } + getAddressesSummary$(addresses: 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/addresses/txs/summary?addresses=${addresses.join(',')}`, { params }); + } + getScriptHashTransactions$(script: string, txid?: string): Observable { let params = new HttpParams(); if (txid) { @@ -160,6 +176,16 @@ export class ElectrsApiService { ); } + getScriptHashesTransactions$(scripts: string[], txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( + switchMap(scriptHashes => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })), + ); + } + getScriptHashSummary$(script: string, txid?: string): Observable { let params = new HttpParams(); if (txid) { @@ -180,6 +206,16 @@ export class ElectrsApiService { ); } + getScriptHashesSummary$(scripts: string[], txid?: string): Observable { + let params = new HttpParams(); + if (txid) { + params = params.append('after_txid', txid); + } + return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( + switchMap(scriptHashes => this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })), + ); + } + getAsset$(assetId: string): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); } From 9c303e8c23391162c38d9eaf6ea83eb7ff7fe365 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 29 Sep 2024 09:11:40 +0000 Subject: [PATCH 2/6] address wallet page by name --- .../addresses-treemap.component.ts | 23 +- .../custom-dashboard.component.ts | 52 +- .../components/wallet/wallet.component.html | 92 +-- .../app/components/wallet/wallet.component.ts | 543 ++++++++---------- .../src/app/graphs/graphs.routing.module.ts | 2 +- .../src/app/interfaces/node-api.interface.ts | 5 +- frontend/src/app/services/state.service.ts | 2 +- 7 files changed, 319 insertions(+), 400 deletions(-) diff --git a/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts index 705941caf..f78b4e2e1 100644 --- a/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts +++ b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts @@ -39,14 +39,19 @@ export class AddressesTreemap implements OnChanges { } prepareChartOptions(): void { - const maxTxs = this.addresses.reduce((max, address) => Math.max(max, address.chain_stats.tx_count), 0); const data = this.addresses.map(address => ({ - address: address.address, - value: address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum, - stats: address.chain_stats, - itemStyle: { - color: lerpColor('#1E88E5', '#D81B60', address.chain_stats.tx_count / maxTxs), - } + address: address.address, + value: address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum, + stats: address.chain_stats, + })); + // only consider visible items for the color gradient + const totalValue = data.reduce((acc, address) => acc + address.value, 0); + const maxTxs = data.filter(address => address.value > (totalValue / 2000)).reduce((max, address) => Math.max(max, address.stats.tx_count), 0); + const dataItems = data.map(address => ({ + ...address, + itemStyle: { + color: lerpColor('#1E88E5', '#D81B60', address.stats.tx_count / maxTxs), + } })); this.chartOptions = { tooltip: { @@ -64,7 +69,7 @@ export class AddressesTreemap implements OnChanges { top: 0, roam: false, type: 'treemap', - data: data, + data: dataItems, nodeClick: 'link', progressive: 100, tooltip: { @@ -87,7 +92,7 @@ export class AddressesTreemap implements OnChanges { ${value.data.address} - Recieved + Received ${this.formatValue(value.data.stats.funded_txo_sum)} diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts index 622e6cf3a..eb9818632 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -370,23 +370,47 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet; this.websocketService.startTrackingWallet(walletName); - this.walletSummary$ = this.apiService.getWallet$(walletName).pipe( + this.walletSummary$ = this.apiService.getWallet$(walletName).pipe( catchError(e => { - return of(null); + return of({}); }), - map((walletTransactions) => { - const transactions = Object.values(walletTransactions).flatMap(wallet => wallet.transactions); - return this.deduplicateWalletTransactions(transactions); - }), - switchMap(initial => this.stateService.walletTransactions$.pipe( - startWith(null), - scan((summary, walletTransactions) => { - if (walletTransactions) { - const transactions: AddressTxSummary[] = [...summary, ...Object.values(walletTransactions).flat()]; - return this.deduplicateWalletTransactions(transactions); + switchMap(wallet => this.stateService.walletTransactions$.pipe( + startWith([]), + scan((summaries, newTransactions) => { + const newSummaries: AddressTxSummary[] = []; + for (const tx of newTransactions) { + const funded: Record = {}; + const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + for (const vin of tx.vin) { + const address = vin.prevout?.scriptpubkey_address; + if (address && wallet[address]) { + spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); + spentCount[address] = (spentCount[address] ?? 0) + 1; + } + } + for (const vout of tx.vout) { + const address = vout.scriptpubkey_address; + if (address && wallet[address]) { + funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); + fundedCount[address] = (fundedCount[address] ?? 0) + 1; + } + } + for (const address of Object.keys({ ...funded, ...spent })) { + // add tx to summary + const txSummary: AddressTxSummary = { + txid: tx.txid, + value: (funded[address] ?? 0) - (spent[address] ?? 0), + height: tx.status.block_height, + time: tx.status.block_time, + }; + wallet[address].transactions?.push(txSummary); + newSummaries.push(txSummary); + } } - return summary; - }, initial) + return [...summaries, ...this.deduplicateWalletTransactions(newSummaries)]; + }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) )), share(), ); diff --git a/frontend/src/app/components/wallet/wallet.component.html b/frontend/src/app/components/wallet/wallet.component.html index 60cc4e264..52b7b02a5 100644 --- a/frontend/src/app/components/wallet/wallet.component.html +++ b/frontend/src/app/components/wallet/wallet.component.html @@ -5,7 +5,7 @@
- +
@@ -35,31 +35,31 @@
- + + + Confirmed balance + + + + Confirmed UTXOs + {{ walletStats.utxos }} + Total received - + - - Total sent - - - - - Balance - - +
- +
- +

Balance History

@@ -67,56 +67,16 @@
- +
+ -
-
-

- Transactions -

-
+ - - -
- - - -
-
-
-
-
-
- -
-
-
- -
-
- -
-
-
- -
- - -
- -
-
- -
- - - -
+
@@ -142,21 +102,11 @@ - +
Error loading wallet data. -
- - There many transactions in this wallet, more than your backend can handle. See more on setting up a stronger backend. -

- Consider viewing this wallet on the official Mempool website instead: -
-
- https://mempool.space/wallet?addresses={{ addressStrings.join(',') }} -
- http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/wallet?addresses={{ addressStrings.join(',') }}

({{ error | httpErrorMsg }})
@@ -172,10 +122,6 @@
- -
- -
diff --git a/frontend/src/app/components/wallet/wallet.component.ts b/frontend/src/app/components/wallet/wallet.component.ts index e91def889..be04e1760 100644 --- a/frontend/src/app/components/wallet/wallet.component.ts +++ b/frontend/src/app/components/wallet/wallet.component.ts @@ -1,15 +1,101 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, filter, catchError, map, tap, share } from 'rxjs/operators'; -import { Address, Transaction } from '../../interfaces/electrs.interface'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators'; +import { Address, AddressTxSummary, ChainStats, Transaction } from '../../interfaces/electrs.interface'; import { WebsocketService } from '../../services/websocket.service'; import { StateService } from '../../services/state.service'; -import { AudioService } from '../../services/audio.service'; import { ApiService } from '../../services/api.service'; -import { of, merge, Subscription, Observable, combineLatest, forkJoin } from 'rxjs'; +import { of, Observable, Subscription } from 'rxjs'; import { SeoService } from '../../services/seo.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; +import { WalletAddress } from '../../interfaces/node-api.interface'; + +class WalletStats implements ChainStats { + addresses: string[]; + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + + constructor (stats: ChainStats[], addresses: string[]) { + Object.assign(this, stats.reduce((acc, stat) => { + acc.funded_txo_count += stat.funded_txo_count; + acc.funded_txo_sum += stat.funded_txo_sum; + acc.spent_txo_count += stat.spent_txo_count; + acc.spent_txo_sum += stat.spent_txo_sum; + return acc; + }, { + funded_txo_count: 0, + funded_txo_sum: 0, + spent_txo_count: 0, + spent_txo_sum: 0, + tx_count: 0, + }) + ); + this.addresses = addresses; + } + + public addTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { + this.spendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (this.addresses.includes(vout.scriptpubkey_address)) { + this.fundTxo(vout.value); + } + } + this.tx_count++; + } + + public removeTx(tx: Transaction): void { + for (const vin of tx.vin) { + if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { + this.unspendTxo(vin.prevout.value); + } + } + for (const vout of tx.vout) { + if (this.addresses.includes(vout.scriptpubkey_address)) { + this.unfundTxo(vout.value); + } + } + this.tx_count--; + } + + private fundTxo(value: number): void { + this.funded_txo_sum += value; + this.funded_txo_count++; + } + + private unfundTxo(value: number): void { + this.funded_txo_sum -= value; + this.funded_txo_count--; + } + + private spendTxo(value: number): void { + this.spent_txo_sum += value; + this.spent_txo_count++; + } + + private unspendTxo(value: number): void { + this.spent_txo_sum -= value; + this.spent_txo_count--; + } + + get balance(): number { + return this.funded_txo_sum - this.spent_txo_sum; + } + + get totalReceived(): number { + return this.funded_txo_sum; + } + + get utxos(): number { + return this.funded_txo_count - this.spent_txo_count; + } +} @Component({ selector: 'app-wallet', @@ -19,16 +105,16 @@ import { seoDescriptionNetwork } from '../../shared/common.utils'; export class WalletComponent implements OnInit, OnDestroy { network = ''; - addresses: Address[]; - addressStrings: string[]; - isLoadingAddress = true; - transactions: Transaction[]; - isLoadingTransactions = true; - retryLoadMore = false; + addresses: Address[] = []; + addressStrings: string[] = []; + walletName: string; + isLoadingWallet = true; + wallet$: Observable>; + walletAddresses$: Observable>; + walletSummary$: Observable; + walletStats$: Observable; error: any; - mainSubscription: Subscription; - wsSubscription: Subscription; - addressLoadingStatus$: Observable; + walletSubscription: Subscription; collapseAddresses: boolean = true; @@ -38,16 +124,10 @@ export class WalletComponent implements OnInit, OnDestroy { sent = 0; chainBalance = 0; - private tempTransactions: Transaction[]; - private timeTxIndexes: number[]; - private lastTransactionTxId: string; - constructor( private route: ActivatedRoute, - private electrsApiService: ElectrsApiService, private websocketService: WebsocketService, private stateService: StateService, - private audioService: AudioService, private apiService: ApiService, private seoService: SeoService, ) { } @@ -55,275 +135,156 @@ export class WalletComponent implements OnInit, OnDestroy { ngOnInit(): void { this.stateService.networkChanged$.subscribe((network) => this.network = network); this.websocketService.want(['blocks']); - - const addresses$ = this.route.queryParamMap.pipe( - map((queryParams) => (queryParams.get('addresses') as string)?.split(',').map(this.normalizeAddress)), - tap(addresses => { - this.addressStrings = addresses; - this.error = undefined; - this.isLoadingAddress = true; - this.fullyLoaded = false; - this.addresses = []; - this.isLoadingTransactions = true; - this.transactions = null; - document.body.scrollTo(0, 0); - const titleLabel = addresses[0] + (addresses.length > 1 ? ` +${addresses.length - 1} addresses` : ''); - this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${titleLabel}}:INTERPOLATION:`); - this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${titleLabel}:INTERPOLATION:.`); + this.wallet$ = this.route.paramMap.pipe( + map((params: ParamMap) => params.get('wallet') as string), + tap((walletName: string) => { + this.walletName = walletName; + this.websocketService.startTrackingWallet(walletName); + this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`); }), - share() + switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe( + catchError((err) => { + this.error = err; + this.seoService.logSoft404(); + console.log(err); + return of({}); + }) + )), + shareReplay(1), ); - this.addressLoadingStatus$ = addresses$ - .pipe( - switchMap(() => this.stateService.loadingIndicators$), - map((indicators) => indicators['address-' + this.addressStrings.join(',')] !== undefined ? indicators['address-' + this.addressStrings.join(',')] : 0) - ); - - this.mainSubscription = combineLatest([ - addresses$, - merge( - of(true), - this.stateService.connectionState$.pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)), - ), - ]).pipe( - switchMap(([addresses]) => { - return forkJoin( - addresses.map((address) => - address.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) - ? this.electrsApiService.getPubKeyAddress$(address) - : this.electrsApiService.getAddress$(address) - ) - ); - }), - tap((addresses: Address[]) => { - this.addresses = addresses; - this.updateChainStats(); - this.isLoadingAddress = false; - this.isLoadingTransactions = true; - this.websocketService.startTrackAddresses(addresses.map(address => address.address)); - }), - switchMap((addresses) => { - return addresses[0].is_pubkey - ? this.electrsApiService.getScriptHashesTransactions$(addresses.map(address => (address.address.length === 66 ? '21' : '41') + address.address + 'ac')) - : this.electrsApiService.getAddressesTransactions$(addresses.map(address => address.address)); - }), - switchMap((transactions) => { - this.tempTransactions = transactions; - if (transactions.length) { - this.lastTransactionTxId = transactions[transactions.length - 1].txid; - } - - const fetchTxs: string[] = []; - this.timeTxIndexes = []; - transactions.forEach((tx, index) => { - if (!tx.status.confirmed) { - fetchTxs.push(tx.txid); - this.timeTxIndexes.push(index); - } - }); - if (!fetchTxs.length) { - return of([]); - } - return this.apiService.getTransactionTimes$(fetchTxs).pipe( - catchError((err) => { - this.isLoadingAddress = false; - this.isLoadingTransactions = false; - this.error = err; - this.seoService.logSoft404(); - console.log(err); - return of([]); - }) - ); - }) - ) - .subscribe((times: number[] | null) => { - if (!times) { - return; + this.walletAddresses$ = this.wallet$.pipe( + map(wallet => { + const walletInfo: Record = {}; + for (const address of Object.keys(wallet)) { + walletInfo[address] = { + address, + chain_stats: wallet[address].stats, + mempool_stats: { + funded_txo_count: 0, + funded_txo_sum: 0, + spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 + }, + }; } - times.forEach((time, index) => { - this.tempTransactions[this.timeTxIndexes[index]].firstSeen = time; - }); - this.tempTransactions.sort((a, b) => { - if (b.status.confirmed) { - if (b.status.block_height === a.status.block_height) { - return b.status.block_time - a.status.block_time; + return walletInfo; + }), + switchMap(initial => this.stateService.walletTransactions$.pipe( + startWith(null), + scan((wallet, walletTransactions) => { + for (const tx of (walletTransactions || [])) { + const funded: Record = {}; + const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + for (const vin of tx.vin) { + const address = vin.prevout?.scriptpubkey_address; + if (address && wallet[address]) { + spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); + spentCount[address] = (spentCount[address] ?? 0) + 1; + } } - return b.status.block_height - a.status.block_height; - } - return b.firstSeen - a.firstSeen; - }); - - this.transactions = this.tempTransactions; - this.isLoadingTransactions = false; - }, - (error) => { - console.log(error); - this.error = error; - this.seoService.logSoft404(); - this.isLoadingAddress = false; - }); - - this.wsSubscription = this.stateService.multiAddressTransactions$.subscribe(update => { - for (const address of Object.keys(update)) { - for (const transaction of update[address].mempool) { - this.addTransaction(transaction); - } - for (const transaction of update[address].confirmed) { - const tx = this.transactions.find((t) => t.txid === transaction.txid); - if (tx) { - this.removeTransaction(tx); - tx.status = transaction.status; - this.transactions = this.transactions.slice(); - this.audioService.playSound('magic'); - } else { - if (this.addTransaction(transaction, false)) { - this.audioService.playSound('magic'); + for (const vout of tx.vout) { + const address = vout.scriptpubkey_address; + if (address && wallet[address]) { + funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); + fundedCount[address] = (fundedCount[address] ?? 0) + 1; + } + } + for (const address of Object.keys({ ...funded, ...spent })) { + // update address stats + wallet[address].chain_stats.tx_count++; + wallet[address].chain_stats.funded_txo_count += fundedCount[address] || 0; + wallet[address].chain_stats.spent_txo_count += spentCount[address] || 0; + wallet[address].chain_stats.funded_txo_sum += funded[address] || 0; + wallet[address].chain_stats.spent_txo_sum += spent[address] || 0; } } - } - for (const transaction of update[address].removed) { - this.removeTransaction(transaction); - } - } - }); - } - - addTransaction(transaction: Transaction, playSound: boolean = true): boolean { - if (this.transactions.some((t) => t.txid === transaction.txid)) { - return false; - } - - this.transactions.unshift(transaction); - this.transactions = this.transactions.slice(); - this.txCount++; - - if (playSound) { - if (transaction.vout.some((vout) => this.addressStrings.includes(vout?.scriptpubkey_address))) { - this.audioService.playSound('cha-ching'); - } else { - this.audioService.playSound('chime'); - } - } - - for (const address of this.addresses) { - let match = false; - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === address.address) { - match = true; - this.sent += vin.prevout.value; - if (transaction.status?.confirmed) { - address.chain_stats.funded_txo_count++; - address.chain_stats.funded_txo_sum += vin.prevout.value; - } else { - address.mempool_stats.funded_txo_count++; - address.mempool_stats.funded_txo_sum += vin.prevout.value; - } - } - }); - transaction.vout.forEach((vout) => { - match = true; - if (vout?.scriptpubkey_address === address.address) { - this.received += vout.value; - } - if (transaction.status?.confirmed) { - address.chain_stats.spent_txo_count++; - address.chain_stats.spent_txo_sum += vout.value; - } else { - address.mempool_stats.spent_txo_count++; - address.mempool_stats.spent_txo_sum += vout.value; - } - }); - if (match) { - if (transaction.status?.confirmed) { - address.chain_stats.tx_count++; - } else { - address.mempool_stats.tx_count++; - } - } - } - - return true; - } - - removeTransaction(transaction: Transaction): boolean { - const index = this.transactions.findIndex(((tx) => tx.txid === transaction.txid)); - if (index === -1) { - return false; - } - - this.transactions.splice(index, 1); - this.transactions = this.transactions.slice(); - this.txCount--; - - for (const address of this.addresses) { - let match = false; - transaction.vin.forEach((vin) => { - if (vin?.prevout?.scriptpubkey_address === address.address) { - match = true; - this.sent -= vin.prevout.value; - if (transaction.status?.confirmed) { - address.chain_stats.funded_txo_count--; - address.chain_stats.funded_txo_sum -= vin.prevout.value; - } else { - address.mempool_stats.funded_txo_count--; - address.mempool_stats.funded_txo_sum -= vin.prevout.value; - } - } - }); - transaction.vout.forEach((vout) => { - match = true; - if (vout?.scriptpubkey_address === address.address) { - this.received -= vout.value; - } - if (transaction.status?.confirmed) { - address.chain_stats.spent_txo_count--; - address.chain_stats.spent_txo_sum -= vout.value; - } else { - address.mempool_stats.spent_txo_count--; - address.mempool_stats.spent_txo_sum -= vout.value; - } - }); - if (match) { - if (transaction.status?.confirmed) { - address.chain_stats.tx_count--; - } else { - address.mempool_stats.tx_count--; - } - } - } - - return true; - } - - loadMore(): void { - if (this.isLoadingTransactions || this.fullyLoaded) { - return; - } - this.isLoadingTransactions = true; - this.retryLoadMore = false; - - (this.addresses[0].is_pubkey - ? this.electrsApiService.getScriptHashesTransactions$(this.addresses.map(address => (address.address.length === 66 ? '21' : '41') + address.address + 'ac'), this.lastTransactionTxId) - : this.electrsApiService.getAddressesTransactions$(this.addresses.map(address => address.address), this.lastTransactionTxId) - ).pipe( - catchError((error) => { - this.isLoadingTransactions = false; - this.retryLoadMore = true; - // In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page. - if (error.status === 422) { - window.location.reload(); - } - return of([]); + return wallet; + }, initial) + )), + tap(() => { + this.isLoadingWallet = false; }) - ).subscribe((transactions: Transaction[]) => { - if (transactions && transactions.length) { - this.lastTransactionTxId = transactions[transactions.length - 1].txid; - this.transactions = this.transactions.concat(transactions); + ); + + this.walletSubscription = this.walletAddresses$.subscribe(wallet => { + this.addressStrings = Object.keys(wallet); + this.addresses = Object.values(wallet); + }); + + this.walletSummary$ = this.wallet$.pipe( + switchMap(wallet => this.stateService.walletTransactions$.pipe( + startWith([]), + scan((summaries, newTransactions) => { + const newSummaries: AddressTxSummary[] = []; + for (const tx of newTransactions) { + const funded: Record = {}; + const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + for (const vin of tx.vin) { + const address = vin.prevout?.scriptpubkey_address; + if (address && wallet[address]) { + spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); + spentCount[address] = (spentCount[address] ?? 0) + 1; + } + } + for (const vout of tx.vout) { + const address = vout.scriptpubkey_address; + if (address && wallet[address]) { + funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); + fundedCount[address] = (fundedCount[address] ?? 0) + 1; + } + } + for (const address of Object.keys({ ...funded, ...spent })) { + // add tx to summary + const txSummary: AddressTxSummary = { + txid: tx.txid, + value: (funded[address] ?? 0) - (spent[address] ?? 0), + height: tx.status.block_height, + time: tx.status.block_time, + }; + wallet[address].transactions?.push(txSummary); + newSummaries.push(txSummary); + } + } + return [...summaries, ...this.deduplicateWalletTransactions(newSummaries)]; + }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) + )), + ); + + this.walletStats$ = this.wallet$.pipe( + switchMap(wallet => { + const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet)); + return this.stateService.walletTransactions$.pipe( + startWith([]), + scan((stats, newTransactions) => { + for (const tx of newTransactions) { + stats.addTx(tx); + } + return stats; + }, walletStats), + ); + }), + ); + } + + deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { + const transactions = new Map(); + for (const tx of walletTransactions) { + if (transactions.has(tx.txid)) { + transactions.get(tx.txid).value += tx.value; } else { - this.fullyLoaded = true; + transactions.set(tx.txid, tx); } - this.isLoadingTransactions = false; + } + return Array.from(transactions.values()).sort((a, b) => { + if (a.height === b.height) { + return b.tx_position - a.tx_position; + } + return b.height - a.height; }); } @@ -335,26 +296,8 @@ export class WalletComponent implements OnInit, OnDestroy { } } - updateChainStats(): void { - let received = 0; - let sent = 0; - let txCount = 0; - let chainBalance = 0; - for (const address of this.addresses) { - received += address.chain_stats.funded_txo_sum + address.mempool_stats.funded_txo_sum; - sent += address.chain_stats.spent_txo_sum + address.mempool_stats.spent_txo_sum; - txCount += address.chain_stats.tx_count + address.mempool_stats.tx_count; - chainBalance += (address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum); - } - this.received = received; - this.sent = sent; - this.txCount = txCount; - this.chainBalance = chainBalance; - } - ngOnDestroy(): void { - this.mainSubscription.unsubscribe(); - this.websocketService.stopTrackingAddresses(); - this.wsSubscription.unsubscribe(); + this.websocketService.stopTrackingWallet(); + this.walletSubscription.unsubscribe(); } } diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index 5e7707a89..b9940fc84 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -90,7 +90,7 @@ const routes: Routes = [ } }, { - path: 'wallet', + path: 'wallet/:wallet', children: [], component: WalletComponent, data: { diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 315ba9b20..0091262e1 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -1,4 +1,4 @@ -import { AddressTxSummary, Block, Transaction } from "./electrs.interface"; +import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface"; export interface OptimizedMempoolStats { added: number; @@ -474,5 +474,6 @@ export interface TxResult { export interface WalletAddress { address: string; active: boolean; - transactions?: AddressTxSummary[]; + stats: ChainStats; + transactions: AddressTxSummary[]; } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 059f3d45c..5e4075a52 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -159,7 +159,7 @@ export class StateService { mempoolRemovedTransactions$ = new Subject(); multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>(); blockTransactions$ = new Subject(); - walletTransactions$ = new Subject>(); + walletTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); isLoadingMempool$ = new BehaviorSubject(true); vbytesPerSecond$ = new ReplaySubject(1); From 756e4356a5bc4e94dfe6bf65e18bb41127b4ad49 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 30 Sep 2024 21:01:10 +0000 Subject: [PATCH 3/6] named wallet sync track txo stats --- backend/src/api/bitcoin/esplora-api.ts | 2 +- backend/src/api/services/wallets.ts | 47 ++++++++++++++++++++------ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index b701aa8a5..9a4b7706a 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -305,7 +305,7 @@ class ElectrsApi implements AbstractBitcoinApi { } $getAddress(address: string): Promise { - throw new Error('Method getAddress not implemented.'); + return this.failoverRouter.$get('/address/' + address); } $getAddressTransactions(address: string, txId?: string): Promise { diff --git a/backend/src/api/services/wallets.ts b/backend/src/api/services/wallets.ts index eea4ee129..dd4d7ebc9 100644 --- a/backend/src/api/services/wallets.ts +++ b/backend/src/api/services/wallets.ts @@ -8,7 +8,14 @@ import { TransactionExtended } from '../../mempool.interfaces'; interface WalletAddress { address: string; active: boolean; - transactions?: IEsploraApi.AddressTxSummary[]; + stats: { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + }; + transactions: IEsploraApi.AddressTxSummary[]; lastSync: number; } @@ -37,7 +44,7 @@ class WalletApi { // resync wallet addresses from the services backend async $syncWallets(): Promise { - if (!config.WALLETS.ENABLED) { + if (!config.WALLETS.ENABLED || this.syncing) { return; } this.syncing = true; @@ -74,10 +81,13 @@ class WalletApi { const refreshTransactions = !wallet.addresses[address.address] || (address.active && (Date.now() - wallet.addresses[address.address].lastSync) > 60 * 60 * 1000); if (refreshTransactions) { try { + const summary = await bitcoinApi.$getAddressTransactionSummary(address.address); + const addressInfo = await bitcoinApi.$getAddress(address.address); const walletAddress: WalletAddress = { address: address.address, active: address.active, - transactions: await bitcoinApi.$getAddressTransactionSummary(address.address), + transactions: summary, + stats: addressInfo.chain_stats, lastSync: Date.now(), }; wallet.addresses[address.address] = walletAddress; @@ -88,36 +98,51 @@ class WalletApi { } // check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets - processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record> { - const walletTransactions: Record> = {}; + processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record { + const walletTransactions: Record = {}; for (const walletKey of Object.keys(this.wallets)) { const wallet = this.wallets[walletKey]; - walletTransactions[walletKey] = {}; + walletTransactions[walletKey] = []; for (const tx of blockTxs) { const funded: Record = {}; const spent: Record = {}; + const fundedCount: Record = {}; + const spentCount: Record = {}; + let anyMatch = false; for (const vin of tx.vin) { const address = vin.prevout?.scriptpubkey_address; if (address && wallet.addresses[address]) { + anyMatch = true; spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); + spentCount[address] = (spentCount[address] ?? 0) + 1; } } for (const vout of tx.vout) { const address = vout.scriptpubkey_address; if (address && wallet.addresses[address]) { + anyMatch = true; funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); + fundedCount[address] = (fundedCount[address] ?? 0) + 1; } } for (const address of Object.keys({ ...funded, ...spent })) { - if (!walletTransactions[walletKey][address]) { - walletTransactions[walletKey][address] = []; - } - walletTransactions[walletKey][address].push({ + // update address stats + wallet.addresses[address].stats.tx_count++; + wallet.addresses[address].stats.funded_txo_count += fundedCount[address] || 0; + wallet.addresses[address].stats.spent_txo_count += spentCount[address] || 0; + wallet.addresses[address].stats.funded_txo_sum += funded[address] || 0; + wallet.addresses[address].stats.spent_txo_sum += spent[address] || 0; + // add tx to summary + const txSummary: IEsploraApi.AddressTxSummary = { txid: tx.txid, value: (funded[address] ?? 0) - (spent[address] ?? 0), height: block.height, time: block.timestamp, - }); + }; + wallet.addresses[address].transactions?.push(txSummary); + } + if (anyMatch) { + walletTransactions[walletKey].push(tx); } } } From f0e207dff2d7c1184f57346662bc254fd554c85c Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 4 Oct 2024 22:32:25 +0000 Subject: [PATCH 4/6] fix wallet balance graph bug --- .../components/custom-dashboard/custom-dashboard.component.ts | 2 +- frontend/src/app/components/wallet/wallet.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts index eb9818632..efbd9e19c 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -409,7 +409,7 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni newSummaries.push(txSummary); } } - return [...summaries, ...this.deduplicateWalletTransactions(newSummaries)]; + return this.deduplicateWalletTransactions([...summaries, ...newSummaries]); }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) )), share(), diff --git a/frontend/src/app/components/wallet/wallet.component.ts b/frontend/src/app/components/wallet/wallet.component.ts index be04e1760..8ace95694 100644 --- a/frontend/src/app/components/wallet/wallet.component.ts +++ b/frontend/src/app/components/wallet/wallet.component.ts @@ -250,9 +250,9 @@ export class WalletComponent implements OnInit, OnDestroy { newSummaries.push(txSummary); } } - return [...summaries, ...this.deduplicateWalletTransactions(newSummaries)]; + return this.deduplicateWalletTransactions([...summaries, ...newSummaries]); }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) - )), + )) ); this.walletStats$ = this.wallet$.pipe( From 2d2c55ce0ec0bd8a5d4b09459749e6957d063a8f Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 4 Oct 2024 22:42:02 +0000 Subject: [PATCH 5/6] Add link to wallet page from custom dashboard widget --- .../custom-dashboard/custom-dashboard.component.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html index 65f0dc0ab..13f49c5df 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -267,9 +267,11 @@
From 602aa4f948143ac10d1d08fea2f510c0ba0d7ca8 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 18 Oct 2024 03:02:30 +0000 Subject: [PATCH 6/6] fix wallet merge conflicts --- .../transactions-list/transactions-list.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bc38e0dd3..5ad1c798c 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -99,7 +99,7 @@
Confidential
@@ -294,7 +294,7 @@