diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts
index 1b320a38a..b1a7f078d 100644
--- a/frontend/src/app/components/address-graph/address-graph.component.ts
+++ b/frontend/src/app/components/address-graph/address-graph.component.ts
@@ -45,14 +45,17 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() left: number | string = 70;
@Input() widget: boolean = false;
@Input() defaultFiat: boolean = false;
+ @Input() showLegend: boolean = true;
+ @Input() showYAxis: boolean = true;
+ adjustedLeft: number;
+ adjustedRight: number;
data: any[] = [];
fiatData: any[] = [];
hoverData: any[] = [];
conversions: any;
allowZoom: boolean = false;
- initialRight = this.right;
- initialLeft = this.left;
+
selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false };
subscription: Subscription;
@@ -181,8 +184,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue);
- this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight;
- this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40;
+ this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
+ this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = {
color: [
@@ -199,10 +202,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
grid: {
top: 20,
bottom: this.allowZoom ? 65 : 20,
- right: this.right,
- left: this.left,
+ right: this.adjustedRight,
+ left: this.adjustedLeft,
},
- legend: !this.stateService.isAnyTestnet() ? {
+ legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? {
data: [
{
name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`,
@@ -313,6 +316,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'value',
position: 'left',
axisLabel: {
+ show: this.showYAxis,
color: 'rgb(110, 112, 121)',
formatter: (val): string => {
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
@@ -343,6 +347,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
{
type: 'value',
axisLabel: {
+ show: this.showYAxis,
color: 'rgb(110, 112, 121)',
formatter: function(val) {
return `$${this.amountShortenerPipe.transform(val, 0, undefined, true)}`;
@@ -399,8 +404,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'slider',
brushSelect: false,
realtime: true,
- left: this.left,
- right: this.right,
+ left: this.adjustedLeft,
+ right: this.adjustedRight,
selectedDataBackground: {
lineStyle: {
color: '#fff',
@@ -430,23 +435,23 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
onLegendSelectChanged(e) {
this.selected = e.selected;
- this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight;
- this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40;
+ this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
+ this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = {
grid: {
- right: this.right,
- left: this.left,
+ right: this.adjustedRight,
+ left: this.adjustedLeft,
},
legend: {
selected: this.selected,
},
dataZoom: this.allowZoom ? [{
- left: this.left,
- right: this.right,
+ left: this.adjustedLeft,
+ right: this.adjustedRight,
}, {
- left: this.left,
- right: this.right,
+ left: this.adjustedLeft,
+ right: this.adjustedRight,
}] : undefined
};
diff --git a/frontend/src/app/components/wallet/wallet-preview.component.html b/frontend/src/app/components/wallet/wallet-preview.component.html
new file mode 100644
index 000000000..b2ce37614
--- /dev/null
+++ b/frontend/src/app/components/wallet/wallet-preview.component.html
@@ -0,0 +1,31 @@
+
+
+ Wallet
+
+
+
+
+
+
+ Addresses |
+ {{ addressStrings.length }} |
+ |
+ UTXOs |
+ {{ walletStats.utxos }} |
+
+
+ Balance (BTC) |
+ 1_000_000_000 ? '1.4-4' : '1.8-8'"> |
+ |
+ Balance (USD) |
+ |
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/wallet/wallet-preview.component.scss b/frontend/src/app/components/wallet/wallet-preview.component.scss
new file mode 100644
index 000000000..62037b901
--- /dev/null
+++ b/frontend/src/app/components/wallet/wallet-preview.component.scss
@@ -0,0 +1,31 @@
+.title-wrapper {
+ padding: 0 15px;
+}
+
+.graph-col {
+ height: 350px;
+ text-align: center;
+ padding: 0;
+ margin-left: 2px;
+ margin-right: 15px;
+}
+
+.table-col {
+ overflow: hidden;
+}
+
+.table {
+ font-size: 32px;
+
+ ::ng-deep .symbol {
+ font-size: 24px;
+ }
+
+ .spacer {
+ background: none;
+ }
+}
+
+.fiat {
+ display: block;
+}
diff --git a/frontend/src/app/components/wallet/wallet-preview.component.ts b/frontend/src/app/components/wallet/wallet-preview.component.ts
new file mode 100644
index 000000000..0387822aa
--- /dev/null
+++ b/frontend/src/app/components/wallet/wallet-preview.component.ts
@@ -0,0 +1,245 @@
+import { Component, OnInit, OnDestroy } from '@angular/core';
+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 { StateService } from '@app/services/state.service';
+import { ApiService } from '@app/services/api.service';
+import { of, Observable, Subscription } from 'rxjs';
+import { SeoService } from '@app/services/seo.service';
+import { seoDescriptionNetwork } from '@app/shared/common.utils';
+import { WalletAddress } from '@interfaces/node-api.interface';
+import { OpenGraphService } from '../../services/opengraph.service';
+import { WebsocketService } from '../../services/websocket.service';
+
+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-preview',
+ templateUrl: './wallet-preview.component.html',
+ styleUrls: ['./wallet-preview.component.scss']
+})
+export class WalletPreviewComponent implements OnInit, OnDestroy {
+ network = '';
+
+ addresses: Address[] = [];
+ addressStrings: string[] = [];
+ walletName: string;
+ isLoadingWallet = true;
+ wallet$: Observable>;
+ walletAddresses$: Observable>;
+ walletSummary$: Observable;
+ walletStats$: Observable;
+ error: any;
+ walletSubscription: Subscription;
+
+ collapseAddresses: boolean = true;
+
+ fullyLoaded = false;
+ txCount = 0;
+ received = 0;
+ sent = 0;
+ chainBalance = 0;
+
+ constructor(
+ private route: ActivatedRoute,
+ private stateService: StateService,
+ private apiService: ApiService,
+ private seoService: SeoService,
+ private websocketService: WebsocketService,
+ private openGraphService: OpenGraphService,
+ ) { }
+
+ ngOnInit(): void {
+ this.websocketService.want(['blocks', 'stats']);
+ this.stateService.networkChanged$.subscribe((network) => this.network = network);
+ this.wallet$ = this.route.paramMap.pipe(
+ map((params: ParamMap) => params.get('wallet') as string),
+ tap((walletName: string) => {
+ this.walletName = walletName;
+ this.openGraphService.waitFor('wallet-addresses-' + this.walletName);
+ this.openGraphService.waitFor('wallet-data-' + this.walletName);
+ this.openGraphService.waitFor('wallet-txs-' + this.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:.`);
+ }),
+ switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe(
+ catchError((err) => {
+ this.error = err;
+ this.seoService.logSoft404();
+ console.log(err);
+ this.openGraphService.fail('wallet-addresses-' + this.walletName);
+ this.openGraphService.fail('wallet-data-' + this.walletName);
+ this.openGraphService.fail('wallet-txs-' + this.walletName);
+ return of({});
+ })
+ )),
+ shareReplay(1),
+ );
+
+ 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
+ },
+ };
+ }
+ return walletInfo;
+ }),
+ tap(() => {
+ this.isLoadingWallet = false;
+ })
+ );
+
+ this.walletSubscription = this.walletAddresses$.subscribe(wallet => {
+ this.addressStrings = Object.keys(wallet);
+ this.addresses = Object.values(wallet);
+ this.openGraphService.waitOver('wallet-addresses-' + this.walletName);
+ });
+
+ this.walletSummary$ = this.wallet$.pipe(
+ map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))),
+ tap(() => {
+ this.openGraphService.waitOver('wallet-txs-' + this.walletName);
+ })
+ );
+
+ 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),
+ );
+ }),
+ tap(() => {
+ this.openGraphService.waitOver('wallet-data-' + this.walletName);
+ })
+ );
+ }
+
+ 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 {
+ transactions.set(tx.txid, tx);
+ }
+ }
+ 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;
+ });
+ }
+
+ 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;
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.walletSubscription.unsubscribe();
+ }
+}
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts
index 4e6b00637..f882b4221 100644
--- a/frontend/src/app/graphs/graphs.module.ts
+++ b/frontend/src/app/graphs/graphs.module.ts
@@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/h
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 { WalletPreviewComponent } from '@components/wallet/wallet-preview.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';
@@ -49,6 +50,7 @@ import { CommonModule } from '@angular/common';
MempoolBlockComponent,
AddressComponent,
WalletComponent,
+ WalletPreviewComponent,
MiningDashboardComponent,
AcceleratorDashboardComponent,
diff --git a/frontend/src/app/previews.routing.module.ts b/frontend/src/app/previews.routing.module.ts
index 92ea113b8..790a8eee8 100644
--- a/frontend/src/app/previews.routing.module.ts
+++ b/frontend/src/app/previews.routing.module.ts
@@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component';
import { BlockPreviewComponent } from '@components/block/block-preview.component';
import { AddressPreviewComponent } from '@components/address/address-preview.component';
+import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
import { PoolPreviewComponent } from '@components/pool/pool-preview.component';
import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component';
@@ -20,6 +21,11 @@ const routes: Routes = [
children: [],
component: AddressPreviewComponent
},
+ {
+ path: 'wallet/:wallet',
+ children: [],
+ component: WalletPreviewComponent
+ },
{
path: 'tx/:id',
children: [],
diff --git a/unfurler/src/routes.ts b/unfurler/src/routes.ts
index 2150f87f3..c6be7e129 100644
--- a/unfurler/src/routes.ts
+++ b/unfurler/src/routes.ts
@@ -85,6 +85,13 @@ const routes = {
return `Address: ${path[0]}`;
}
},
+ wallet: {
+ render: true,
+ params: 1,
+ getTitle(path) {
+ return `Wallet: ${path[0]}`;
+ }
+ },
blocks: {
title: "Blocks",
fallbackImg: '/resources/previews/blocks.jpg',
@@ -289,6 +296,7 @@ export const networks = {
routes: { // only dynamic routes supported
block: routes.block,
address: routes.address,
+ wallet: routes.wallet,
tx: routes.tx,
mining: {
title: "Mining",
@@ -309,6 +317,7 @@ export const networks = {
routes: { // only dynamic routes supported
block: routes.block,
address: routes.address,
+ wallet: routes.wallet,
tx: routes.tx,
mining: {
title: "Mining",