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); } } } 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..f78b4e2e1 --- /dev/null +++ b/frontend/src/app/components/addresses-treemap/addresses-treemap.component.ts @@ -0,0 +1,150 @@ +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 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, + })); + // 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: { + trigger: 'item', + textStyle: { + align: 'left', + } + }, + series: [ + { + height: 300, + left: 0, + right: 0, + bottom: 0, + top: 0, + roam: false, + type: 'treemap', + data: dataItems, + 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}
Received${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/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 @@
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..efbd9e19c 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 this.deduplicateWalletTransactions([...summaries, ...newSummaries]); + }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) )), share(), ); 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..5ad1c798c 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 @@ @@ -99,7 +99,7 @@ @@ -214,7 +214,7 @@ @@ -294,7 +294,7 @@ @@ -353,7 +353,7 @@ -