mempool/frontend/src/app/components/transactions-list/transactions-list.component.ts

355 lines
12 KiB
TypeScript
Raw Normal View History

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';
2022-11-24 12:19:19 +09:00
import { environment } from '../../../environments/environment';
2022-09-21 17:23:45 +02:00
import { AssetsService } from '../../services/assets.service';
import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/operators';
2022-09-21 17:23:45 +02:00
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 = '';
2021-12-28 17:59:11 +04:00
nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId;
showMoreIncrement = 1000;
2020-05-02 12:36:35 +07:00
@Input() transactions: Transaction[];
2023-03-06 00:02:21 -06:00
@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;
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
@Output() loadMore = new EventEmitter();
latestBlock$: Observable<BlockExtended>;
outspendsSubscription: Subscription;
currencyChangeSubscription: Subscription;
currency: string;
2022-06-22 23:34:44 +02:00
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
2022-05-07 11:32:15 +04:00
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
2022-03-06 18:27:13 +01:00
showDetails$ = new BehaviorSubject<boolean>(false);
2020-05-02 12:36:35 +07:00
assetsMinimal: any;
transactionsLength: number = 0;
inputRowLimit: number = 12;
outputRowLimit: number = 12;
constructor(
public stateService: StateService,
private cacheService: CacheService,
private electrsApiService: ElectrsApiService,
2022-06-22 23:34:44 +02:00
private apiService: ApiService,
2020-05-02 12:36:35 +07:00
private assetsService: AssetsService,
private ref: ChangeDetectorRef,
private priceService: PriceService,
) { }
ngOnInit(): void {
2023-07-08 01:07:06 -04:00
this.latestBlock$ = this.stateService.blocks$.pipe(map((blocks) => blocks[0]));
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(
2023-03-06 00:02:21 -06:00
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 => { return this.electrsApiService.cachedRequest(this.electrsApiService.getOutspendsBatched$, 250, batch); }));
2023-03-06 00:02:21 -06:00
} else {
return of([]);
}
}),
tap((batchedOutspends: Outspend[][][]) => {
// flatten batched results back into a single array
const outspends = batchedOutspends.flat(1);
2022-08-28 17:48:51 +02:00
if (!this.transactions) {
return;
}
const transactions = this.transactions.filter((tx) => !tx._outspends);
outspends.forEach((outspend, i) => {
transactions[i]._outspends = outspend;
});
this.ref.markForCheck();
}),
),
this.stateService.utxoSpent$
.pipe(
2022-06-22 23:34:44 +02:00
tap((utxoSpent) => {
for (const i in utxoSpent) {
this.transactions[0]._outspends[i] = {
spent: true,
txid: utxoSpent[i].txid,
vin: utxoSpent[i].vin,
};
}
}),
2022-05-07 11:32:15 +04:00
),
this.refreshChannels$
.pipe(
filter(() => this.stateService.env.LIGHTNING),
2022-05-07 11:32:15 +04:00
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
catchError((error) => {
// handle 404
return of([]);
}),
tap((channels) => {
if (!this.transactions) {
return;
}
const transactions = this.transactions.filter((tx) => !tx._channels);
channels.forEach((channel, i) => {
transactions[i]._channels = channel;
});
2022-05-07 11:32:15 +04:00
}),
)
,
).subscribe(() => this.ref.markForCheck());
this.currencyChangeSubscription = this.stateService.fiatCurrency$
.subscribe(currency => {
this.currency = currency;
this.refreshPrice();
});
}
refreshPrice(): void {
// Loop over all transactions
if (!this.transactions || !this.transactions.length || !this.currency) {
return;
}
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
if (!this.blockTime) {
this.transactions.forEach((tx) => {
if (!this.blockTime) {
if (tx.status.block_time) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe(
tap((price) => tx['price'] = price),
).subscribe();
}
}
});
} else {
this.priceService.getBlockPrice$(this.blockTime, true, this.currency).pipe(
tap((price) => this.transactions.forEach((tx) => tx['price'] = price)),
).subscribe();
}
}
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);
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
this.transactions.forEach((tx) => {
tx['@voutLimit'] = true;
tx['@vinLimit'] = true;
if (tx['addressValue'] !== undefined) {
return;
}
if (this.address) {
const isP2PKUncompressed = this.address.length === 130;
const isP2PKCompressed = this.address.length === 66;
if (isP2PKCompressed) {
const addressIn = tx.vout
.filter((v: Vout) => v.scriptpubkey === '21' + this.address + 'ac')
.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 === '21' + this.address + 'ac')
.map((v: Vin) => v.prevout.value || 0)
.reduce((a: number, b: number) => a + b, 0);
tx['addressValue'] = addressIn - addressOut;
} else if (isP2PKUncompressed) {
const addressIn = tx.vout
.filter((v: Vout) => v.scriptpubkey === '41' + this.address + 'ac')
.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 === '41' + this.address + 'ac')
.map((v: Vin) => v.prevout.value || 0)
.reduce((a: number, b: number) => a + b, 0);
tx['addressValue'] = addressIn - addressOut;
} else {
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;
}
}
if (!this.blockTime && tx.status.block_time && this.currency) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe(
tap((price) => tx['price'] = price),
).subscribe();
}
});
if (this.blockTime && this.transactions?.length && this.currency) {
this.priceService.getBlockPrice$(this.blockTime, true, this.currency).pipe(
tap((price) => this.transactions.forEach((tx) => tx['price'] = price)),
).subscribe();
}
const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid);
2023-03-06 00:02:21 -06:00
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') {
2020-05-02 12:36:35 +07:00
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 {
2022-03-06 18:27:13 +01:00
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();
this.currencyChangeSubscription?.unsubscribe();
}
}