mirror of
https://github.com/mempool/mempool.git
synced 2025-02-21 14:04:15 +01:00
Add basic transaction link previews
This commit is contained in:
parent
a6b1d4059f
commit
e0ea47b8ee
6 changed files with 447 additions and 0 deletions
|
@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
|
|||
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
|
||||
import { StartComponent } from './components/start/start.component';
|
||||
import { TransactionComponent } from './components/transaction/transaction.component';
|
||||
import { TransactionPreviewComponent } from './components/transaction/transaction-preview.component';
|
||||
import { BlockComponent } from './components/block/block.component';
|
||||
import { BlockAuditComponent } from './components/block-audit/block-audit.component';
|
||||
import { BlockPreviewComponent } from './components/block/block-preview.component';
|
||||
|
@ -366,6 +367,21 @@ let routes: Routes = [
|
|||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'tx/:id',
|
||||
children: [],
|
||||
component: TransactionPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/tx/:id',
|
||||
children: [],
|
||||
component: TransactionPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'signet/tx/:id',
|
||||
children: [],
|
||||
component: TransactionPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'lightning',
|
||||
loadChildren: () => import('./lightning/lightning-previews.module').then(m => m.LightningPreviewsModule)
|
||||
|
@ -643,6 +659,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
|||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'tx/:id',
|
||||
children: [],
|
||||
component: TransactionPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/tx/:id',
|
||||
children: [],
|
||||
component: TransactionPreviewComponent
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
<div class="box preview-box" *ngIf="tx && !error">
|
||||
|
||||
<div class="page-title">
|
||||
<h1 i18n="shared.transaction">Transaction</h1>
|
||||
<div *ngIf="network !== 'liquid' && network !== 'liquidtestnet'" class="features">
|
||||
<app-tx-features [tx]="tx"></app-tx-features>
|
||||
<span *ngIf="cpfpInfo && cpfpInfo.bestDescendant" class="badge badge-primary mr-1">
|
||||
CPFP
|
||||
</span>
|
||||
<span *ngIf="cpfpInfo && !cpfpInfo.bestDescendant && cpfpInfo.ancestors.length" class="badge badge-info mr-1">
|
||||
CPFP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a [routerLink]="['/tx/' | relativeUrl, txId]" class="tx-link">
|
||||
{{ txId }}
|
||||
</a>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr *ngIf="tx.status.confirmed; else firstSeen">
|
||||
<td i18n="block.timestamp">Timestamp</td>
|
||||
<td>
|
||||
‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #firstSeen>
|
||||
<tr>
|
||||
<td i18n="transaction.first-seen|Transaction first seen">First seen</td>
|
||||
<td *ngIf="transactionTime > 0; else notSeen">
|
||||
‎{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
||||
</td>
|
||||
<ng-template #notSeen>
|
||||
<td>?</td>
|
||||
</ng-template>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<tr>
|
||||
<td class="td-width" i18n="dashboard.latest-transactions.amount">Amount</td>
|
||||
<td>
|
||||
<ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && haveBlindedOutputValues(tx)" [ngIfElse]="defaultAmount" i18n="shared.confidential">Confidential</ng-template>
|
||||
<ng-template #defaultAmount>
|
||||
<app-amount [satoshis]="getTotalTxOutput(tx)"></app-amount>
|
||||
</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="block.size">Size</td>
|
||||
<td [innerHTML]="'‎' + (tx.size | bytes: 2)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="block.weight">Weight</td>
|
||||
<td [innerHTML]="'‎' + (tx.weight | wuBytes: 2)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="transaction.inputs">Inputs</td>
|
||||
<td *ngIf="!isCoinbase(tx); else coinbaseInputs">{{ tx.vin.length }}</td>
|
||||
<ng-template #coinbaseInputs>
|
||||
<td i18n="transactions-list.coinbase">Coinbase</td>
|
||||
</ng-template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-sm">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
|
||||
</tr>
|
||||
<tr *ngIf="!cpfpInfo || (!cpfpInfo.bestDescendant && !cpfpInfo.ancestors.length); else cpfpFee">
|
||||
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
|
||||
<td>
|
||||
{{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||
<ng-template [ngIf]="tx.status.confirmed">
|
||||
|
||||
<app-tx-fee-rating *ngIf="tx.fee && ((cpfpInfo && !cpfpInfo.bestDescendant && !cpfpInfo.ancestors.length) || !cpfpInfo)" [tx]="tx"></app-tx-fee-rating>
|
||||
</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #cpfpFee>
|
||||
<tr>
|
||||
<td i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
|
||||
<td>
|
||||
<div class="effective-fee-container">
|
||||
{{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
|
||||
<ng-template [ngIf]="tx.status.confirmed">
|
||||
<app-tx-fee-rating class="d-none d-lg-inline ml-2" *ngIf="tx.fee" [tx]="tx"></app-tx-fee-rating>
|
||||
</ng-template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<tr>
|
||||
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
|
||||
<td [innerHTML]="'‎' + (tx.weight / 4 | vbytes: 2)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="transaction.locktime">Locktime</td>
|
||||
<td [innerHTML]="'‎' + (tx.locktime | number)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="transaction.outputs">Outputs</td>
|
||||
<td>{{ tx.vout.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,75 @@
|
|||
.adjust-btn-padding {
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.td-width {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
::ng-deep .badge {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btn-small-height {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.arrow-green {
|
||||
color: #1a9436;
|
||||
}
|
||||
|
||||
.arrow-red {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.effective-fee-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.title {
|
||||
h2 {
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-info {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h1 {
|
||||
font-size: 52px;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.features {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 32px;
|
||||
|
||||
::ng-deep .symbol {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.tx-link {
|
||||
display: inline-block;
|
||||
font-size: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
|
@ -0,0 +1,224 @@
|
|||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import {
|
||||
switchMap,
|
||||
filter,
|
||||
catchError,
|
||||
retryWhen,
|
||||
delay,
|
||||
map
|
||||
} from 'rxjs/operators';
|
||||
import { Transaction, Vout } from '../../interfaces/electrs.interface';
|
||||
import { of, merge, Subscription, Observable, Subject, timer, combineLatest, from } from 'rxjs';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { OpenGraphService } from 'src/app/services/opengraph.service';
|
||||
import { ApiService } from 'src/app/services/api.service';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { CpfpInfo } from 'src/app/interfaces/node-api.interface';
|
||||
import { LiquidUnblinding } from './liquid-ublinding';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-preview',
|
||||
templateUrl: './transaction-preview.component.html',
|
||||
styleUrls: ['./transaction-preview.component.scss'],
|
||||
})
|
||||
export class TransactionPreviewComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
tx: Transaction;
|
||||
txId: string;
|
||||
isLoadingTx = true;
|
||||
error: any = undefined;
|
||||
errorUnblinded: any = undefined;
|
||||
transactionTime = -1;
|
||||
subscription: Subscription;
|
||||
fetchCpfpSubscription: Subscription;
|
||||
cpfpInfo: CpfpInfo | null;
|
||||
showCpfpDetails = false;
|
||||
fetchCpfp$ = new Subject<string>();
|
||||
liquidUnblinding = new LiquidUnblinding();
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private electrsApiService: ElectrsApiService,
|
||||
private stateService: StateService,
|
||||
private apiService: ApiService,
|
||||
private seoService: SeoService,
|
||||
private openGraphService: OpenGraphService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.stateService.networkChanged$.subscribe(
|
||||
(network) => (this.network = network)
|
||||
);
|
||||
|
||||
this.fetchCpfpSubscription = this.fetchCpfp$
|
||||
.pipe(
|
||||
switchMap((txId) =>
|
||||
this.apiService
|
||||
.getCpfpinfo$(txId)
|
||||
.pipe(retryWhen((errors) => errors.pipe(delay(2000))))
|
||||
)
|
||||
)
|
||||
.subscribe((cpfpInfo) => {
|
||||
if (!this.tx) {
|
||||
return;
|
||||
}
|
||||
const lowerFeeParents = cpfpInfo.ancestors.filter(
|
||||
(parent) => parent.fee / (parent.weight / 4) < this.tx.feePerVsize
|
||||
);
|
||||
let totalWeight =
|
||||
this.tx.weight +
|
||||
lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalFees =
|
||||
this.tx.fee +
|
||||
lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
|
||||
if (cpfpInfo.bestDescendant) {
|
||||
totalWeight += cpfpInfo.bestDescendant.weight;
|
||||
totalFees += cpfpInfo.bestDescendant.fee;
|
||||
}
|
||||
|
||||
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
|
||||
this.stateService.markBlock$.next({
|
||||
txFeePerVSize: this.tx.effectiveFeePerVsize,
|
||||
});
|
||||
this.cpfpInfo = cpfpInfo;
|
||||
this.openGraphService.waitOver('cpfp-data');
|
||||
});
|
||||
|
||||
this.subscription = this.route.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
this.openGraphService.waitFor('tx-data');
|
||||
const urlMatch = (params.get('id') || '').split(':');
|
||||
this.txId = urlMatch[0];
|
||||
this.seoService.setTitle(
|
||||
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
|
||||
);
|
||||
this.resetTransaction();
|
||||
return merge(
|
||||
of(true),
|
||||
this.stateService.connectionState$.pipe(
|
||||
filter(
|
||||
(state) => state === 2 && this.tx && !this.tx.status.confirmed
|
||||
)
|
||||
)
|
||||
);
|
||||
}),
|
||||
switchMap(() => {
|
||||
let transactionObservable$: Observable<Transaction>;
|
||||
if (history.state.data && history.state.data.fee !== -1) {
|
||||
transactionObservable$ = of(history.state.data);
|
||||
} else {
|
||||
transactionObservable$ = this.electrsApiService
|
||||
.getTransaction$(this.txId)
|
||||
.pipe(
|
||||
catchError(error => {
|
||||
this.error = error;
|
||||
this.isLoadingTx = false;
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
return merge(
|
||||
transactionObservable$,
|
||||
this.stateService.mempoolTransactions$
|
||||
);
|
||||
}),
|
||||
switchMap((tx) => {
|
||||
if (this.network === 'liquid' || this.network === 'liquidtestnet') {
|
||||
return from(this.liquidUnblinding.checkUnblindedTx(tx))
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
this.errorUnblinded = error;
|
||||
return of(tx);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(tx);
|
||||
})
|
||||
)
|
||||
.subscribe((tx: Transaction) => {
|
||||
if (!tx) {
|
||||
this.openGraphService.fail('tx-data');
|
||||
return;
|
||||
}
|
||||
|
||||
this.tx = tx;
|
||||
if (tx.fee === undefined) {
|
||||
this.tx.fee = 0;
|
||||
}
|
||||
this.tx.feePerVsize = tx.fee / (tx.weight / 4);
|
||||
this.isLoadingTx = false;
|
||||
this.error = undefined;
|
||||
|
||||
if (!tx.status.confirmed && tx.firstSeen) {
|
||||
this.transactionTime = tx.firstSeen;
|
||||
} else {
|
||||
this.getTransactionTime();
|
||||
}
|
||||
|
||||
if (!this.tx.status.confirmed) {
|
||||
if (tx.cpfpChecked) {
|
||||
this.cpfpInfo = {
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant,
|
||||
};
|
||||
} else {
|
||||
this.openGraphService.waitFor('cpfp-data');
|
||||
this.fetchCpfp$.next(this.tx.txid);
|
||||
}
|
||||
}
|
||||
|
||||
this.openGraphService.waitOver('tx-data');
|
||||
},
|
||||
(error) => {
|
||||
this.openGraphService.fail('tx-data');
|
||||
this.error = error;
|
||||
this.isLoadingTx = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getTransactionTime() {
|
||||
this.openGraphService.waitFor('tx-time');
|
||||
this.apiService
|
||||
.getTransactionTimes$([this.tx.txid])
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
return of(0);
|
||||
})
|
||||
)
|
||||
.subscribe((transactionTimes) => {
|
||||
this.transactionTime = transactionTimes[0];
|
||||
this.openGraphService.waitOver('tx-time');
|
||||
});
|
||||
}
|
||||
|
||||
resetTransaction() {
|
||||
this.error = undefined;
|
||||
this.tx = null;
|
||||
this.isLoadingTx = true;
|
||||
this.transactionTime = -1;
|
||||
this.cpfpInfo = null;
|
||||
this.showCpfpDetails = false;
|
||||
}
|
||||
|
||||
isCoinbase(tx: Transaction): boolean {
|
||||
return tx.vin.some((v: any) => v.is_coinbase === true);
|
||||
}
|
||||
|
||||
haveBlindedOutputValues(tx: Transaction): boolean {
|
||||
return tx.vout.some((v: any) => v.value === undefined);
|
||||
}
|
||||
|
||||
getTotalTxOutput(tx: Transaction) {
|
||||
return tx.vout.map((v: Vout) => v.value || 0).reduce((a: number, b: number) => a + b);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscription.unsubscribe();
|
||||
this.fetchCpfpSubscription.unsubscribe();
|
||||
}
|
||||
}
|
|
@ -43,6 +43,7 @@ import { RouterModule } from '@angular/router';
|
|||
import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe';
|
||||
import { StartComponent } from '../components/start/start.component';
|
||||
import { TransactionComponent } from '../components/transaction/transaction.component';
|
||||
import { TransactionPreviewComponent } from '../components/transaction/transaction-preview.component';
|
||||
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
||||
import { BlockComponent } from '../components/block/block.component';
|
||||
import { BlockPreviewComponent } from '../components/block/block-preview.component';
|
||||
|
@ -118,6 +119,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
|
|||
LiquidMasterPageComponent,
|
||||
StartComponent,
|
||||
TransactionComponent,
|
||||
TransactionPreviewComponent,
|
||||
BlockComponent,
|
||||
BlockPreviewComponent,
|
||||
BlockAuditComponent,
|
||||
|
@ -220,6 +222,7 @@ import { ToggleComponent } from './components/toggle/toggle.component';
|
|||
AmountComponent,
|
||||
StartComponent,
|
||||
TransactionComponent,
|
||||
TransactionPreviewComponent,
|
||||
BlockComponent,
|
||||
BlockPreviewComponent,
|
||||
BlockAuditComponent,
|
||||
|
|
|
@ -157,6 +157,9 @@ class Server {
|
|||
case 'address':
|
||||
ogTitle = `Address: ${parts[1]}`;
|
||||
break;
|
||||
case 'tx':
|
||||
ogTitle = `Transaction: ${parts[1]}`;
|
||||
break;
|
||||
case 'lightning':
|
||||
switch (parts[1]) {
|
||||
case 'node':
|
||||
|
|
Loading…
Add table
Reference in a new issue