From e0ea47b8eeb33bbdace3e2cdc98af05f5522f063 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Mon, 15 Aug 2022 23:14:34 +0000 Subject: [PATCH] Add basic transaction link previews --- frontend/src/app/app-routing.module.ts | 26 ++ .../transaction-preview.component.html | 116 +++++++++ .../transaction-preview.component.scss | 75 ++++++ .../transaction-preview.component.ts | 224 ++++++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + unfurler/src/index.ts | 3 + 6 files changed, 447 insertions(+) create mode 100644 frontend/src/app/components/transaction/transaction-preview.component.html create mode 100644 frontend/src/app/components/transaction/transaction-preview.component.scss create mode 100644 frontend/src/app/components/transaction/transaction-preview.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index bd2d8c541..f4c6dbbc8 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -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 + }, ], }, { diff --git a/frontend/src/app/components/transaction/transaction-preview.component.html b/frontend/src/app/components/transaction/transaction-preview.component.html new file mode 100644 index 000000000..f9f62c417 --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-preview.component.html @@ -0,0 +1,116 @@ +
+ +
+

Transaction

+
+ + + CPFP + + + CPFP + +
+
+ + + {{ txId }} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Timestamp + ‎{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} +
First seen + ‎{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }} + ?
Amount + Confidential + + + +
Size
Weight
Inputs{{ tx.vin.length }}Coinbase
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fee{{ tx.fee | number }} sat
Fee rate + {{ tx.feePerVsize | feeRounding }} sat/vB + +   + + +
Effective fee rate +
+ {{ tx.effectiveFeePerVsize | feeRounding }} sat/vB + + + +
+
Virtual size
Locktime
Outputs{{ tx.vout.length }}
+
+
+
diff --git a/frontend/src/app/components/transaction/transaction-preview.component.scss b/frontend/src/app/components/transaction/transaction-preview.component.scss new file mode 100644 index 000000000..a8e2a0acb --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-preview.component.scss @@ -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; +} diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts new file mode 100644 index 000000000..05ce623fb --- /dev/null +++ b/frontend/src/app/components/transaction/transaction-preview.component.ts @@ -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(); + 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; + 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(); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 48e9eb46e..bfd47e411 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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, diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index cd6b7762f..08dff3964 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -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':