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 |
+ 0; else notSeen">
+ {{ 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':