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(); } }