Add basic transaction link previews

This commit is contained in:
Mononaut 2022-08-15 23:14:34 +00:00
parent a6b1d4059f
commit e0ea47b8ee
No known key found for this signature in database
GPG key ID: 61B952CAF4838F94
6 changed files with 447 additions and 0 deletions

View file

@ -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
},
],
},
{

View file

@ -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>
&lrm;{{ 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">
&lrm;{{ 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]="'&lrm;' + (tx.size | bytes: 2)"></td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (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">
&nbsp;
<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]="'&lrm;' + (tx.weight / 4 | vbytes: 2)"></td>
</tr>
<tr>
<td i18n="transaction.locktime">Locktime</td>
<td [innerHTML]="'&lrm;' + (tx.locktime | number)"></td>
</tr>
<tr>
<td i18n="transaction.outputs">Outputs</td>
<td>{{ tx.vout.length }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View file

@ -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;
}

View file

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

View file

@ -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,

View file

@ -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':