From dcae94ba666f0087c5fbb353e0104cc67529f805 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 10 Dec 2024 00:28:41 +0000 Subject: [PATCH] add wallet unfurler preview --- .../wallet/wallet-preview.component.html | 31 +++ .../wallet/wallet-preview.component.scss | 31 +++ .../wallet/wallet-preview.component.ts | 245 ++++++++++++++++++ frontend/src/app/graphs/graphs.module.ts | 2 + frontend/src/app/previews.routing.module.ts | 6 + unfurler/src/routes.ts | 9 + 6 files changed, 324 insertions(+) create mode 100644 frontend/src/app/components/wallet/wallet-preview.component.html create mode 100644 frontend/src/app/components/wallet/wallet-preview.component.scss create mode 100644 frontend/src/app/components/wallet/wallet-preview.component.ts 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)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",