import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; import { CacheService } from '../../services/cache.service'; import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription, of, forkJoin } from 'rxjs'; import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { environment } from '../../../environments/environment'; import { AssetsService } from '../../services/assets.service'; import { filter, map, tap, switchMap, shareReplay } from 'rxjs/operators'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { PriceService } from '../../services/price.service'; @Component({ selector: 'app-transactions-list', templateUrl: './transactions-list.component.html', styleUrls: ['./transactions-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class TransactionsListComponent implements OnInit, OnChanges { network = ''; nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId; showMoreIncrement = 1000; @Input() transactions: Transaction[]; @Input() cached: boolean = false; @Input() showConfirmations = false; @Input() transactionPage = false; @Input() errorUnblinded = false; @Input() paginated = false; @Input() inputIndex: number; @Input() outputIndex: number; @Input() address: string = ''; @Input() rowLimit = 12; @Output() loadMore = new EventEmitter(); latestBlock$: Observable; outspendsSubscription: Subscription; refreshOutspends$: ReplaySubject = new ReplaySubject(); refreshChannels$: ReplaySubject = new ReplaySubject(); showDetails$ = new BehaviorSubject(false); assetsMinimal: any; transactionsLength: number = 0; inputRowLimit: number = 12; outputRowLimit: number = 12; constructor( public stateService: StateService, private cacheService: CacheService, private electrsApiService: ElectrsApiService, private apiService: ApiService, private assetsService: AssetsService, private ref: ChangeDetectorRef, private priceService: PriceService, ) { } ngOnInit(): void { this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block)); this.stateService.networkChanged$.subscribe((network) => this.network = network); if (this.network === 'liquid' || this.network === 'liquidtestnet') { this.assetsService.getAssetsMinimalJson$.subscribe((assets) => { this.assetsMinimal = assets; }); } this.outspendsSubscription = merge( this.refreshOutspends$ .pipe( switchMap((txIds) => { if (!this.cached) { // break list into batches of 50 (maximum supported by esplora) const batches = []; for (let i = 0; i < txIds.length; i += 50) { batches.push(txIds.slice(i, i + 50)); } return forkJoin(batches.map(batch => this.apiService.getOutspendsBatched$(batch))); } else { return of([]); } }), tap((batchedOutspends: Outspend[][][]) => { // flatten batched results back into a single array const outspends = batchedOutspends.flat(1); if (!this.transactions) { return; } const transactions = this.transactions.filter((tx) => !tx._outspends); outspends.forEach((outspend, i) => { transactions[i]._outspends = outspend; }); }), ), this.stateService.utxoSpent$ .pipe( tap((utxoSpent) => { for (const i in utxoSpent) { this.transactions[0]._outspends[i] = { spent: true, txid: utxoSpent[i].txid, vin: utxoSpent[i].vin, }; } }), ), this.refreshChannels$ .pipe( filter(() => this.stateService.env.LIGHTNING), switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)), tap((channels) => { if (!this.transactions) { return; } const transactions = this.transactions.filter((tx) => !tx._channels); channels.forEach((channel, i) => { transactions[i]._channels = channel; }); }), ) , ).subscribe(() => this.ref.markForCheck()); } ngOnChanges(changes): void { if (changes.inputIndex || changes.outputIndex || changes.rowLimit) { this.inputRowLimit = Math.max(this.rowLimit, (this.inputIndex || 0) + 3); this.outputRowLimit = Math.max(this.rowLimit, (this.outputIndex || 0) + 3); if ((this.inputIndex || this.outputIndex) && !changes.transactions) { setTimeout(() => { const assetBoxElements = document.getElementsByClassName('assetBox'); if (assetBoxElements && assetBoxElements[0]) { assetBoxElements[0].scrollIntoView({block: "center"}); } }, 10); } } if (changes.transactions || changes.address) { if (!this.transactions || !this.transactions.length) { return; } this.transactionsLength = this.transactions.length; this.cacheService.setTxCache(this.transactions); this.transactions.forEach((tx) => { tx['@voutLimit'] = true; tx['@vinLimit'] = true; if (tx['addressValue'] !== undefined) { return; } if (this.address) { const addressIn = tx.vout .filter((v: Vout) => v.scriptpubkey_address === this.address) .map((v: Vout) => v.value || 0) .reduce((a: number, b: number) => a + b, 0); const addressOut = tx.vin .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) .map((v: Vin) => v.prevout.value || 0) .reduce((a: number, b: number) => a + b, 0); tx['addressValue'] = addressIn - addressOut; } this.priceService.getBlockPrice$(tx.status.block_time).pipe( tap((price) => tx['price'] = price) ).subscribe(); }); const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); if (txIds.length && !this.cached) { this.refreshOutspends$.next(txIds); } if (this.stateService.env.LIGHTNING) { const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); if (txIds.length) { this.refreshChannels$.next(txIds); } } } } onScroll(): void { this.loadMore.emit(); } haveBlindedOutputValues(tx: Transaction): boolean { return tx.vout.some((v: any) => v.value === undefined); } getTotalTxOutput(tx: Transaction): number { return tx.vout.map((v: Vout) => v.value || 0).reduce((a: number, b: number) => a + b); } switchCurrency(): void { if (this.network === 'liquid' || this.network === 'liquidtestnet') { return; } const oldvalue = !this.stateService.viewFiat$.value; this.stateService.viewFiat$.next(oldvalue); } trackByFn(index: number, tx: Transaction): string { return tx.txid + tx.status.confirmed; } trackByIndexFn(index: number): number { return index; } formatHex(num: number): string { const str = num.toString(16); return '0x' + (str.length % 2 ? '0' : '') + str; } pow(base: number, exponent: number): number { return Math.pow(base, exponent); } toggleDetails(): void { if (this.showDetails$.value === true) { this.showDetails$.next(false); } else { this.showDetails$.next(true); } } loadMoreInputs(tx: Transaction): void { if (!tx['@vinLoaded']) { this.electrsApiService.getTransaction$(tx.txid) .subscribe((newTx) => { tx['@vinLoaded'] = true; tx.vin = newTx.vin; tx.fee = newTx.fee; this.ref.markForCheck(); }); } } showMoreInputs(tx: Transaction): void { this.loadMoreInputs(tx); tx['@vinLimit'] = this.getVinLimit(tx, true); } showMoreOutputs(tx: Transaction): void { tx['@voutLimit'] = this.getVoutLimit(tx, true); } getVinLimit(tx: Transaction, next = false): number { let limit; if ((tx['@vinLimit'] || 0) > this.inputRowLimit) { limit = Math.min(tx['@vinLimit'] + (next ? this.showMoreIncrement : 0), tx.vin.length); } else { limit = Math.min((next ? this.showMoreIncrement : this.inputRowLimit), tx.vin.length); } if (tx.vin.length - limit <= 5) { limit = tx.vin.length; } return limit; } getVoutLimit(tx: Transaction, next = false): number { let limit; if ((tx['@voutLimit'] || 0) > this.outputRowLimit) { limit = Math.min(tx['@voutLimit'] + (next ? this.showMoreIncrement : 0), tx.vout.length); } else { limit = Math.min((next ? this.showMoreIncrement : this.outputRowLimit), tx.vout.length); } if (tx.vout.length - limit <= 5) { limit = tx.vout.length; } return limit; } ngOnDestroy(): void { this.outspendsSubscription.unsubscribe(); } }