diff --git a/frontend/src/app/components/transaction/transaction-preview.component.html b/frontend/src/app/components/transaction/transaction-preview.component.html
index f9f62c417..991f51948 100644
--- a/frontend/src/app/components/transaction/transaction-preview.component.html
+++ b/frontend/src/app/components/transaction/transaction-preview.component.html
@@ -13,104 +13,47 @@
-
- {{ 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 |
-
-
-
-
+
+
+
+
+ Confidential
+
+
+
+
+
+ {{ tx.feePerVsize | feeRounding }} sat/vB
+
-
-
-
-
-
- Fee |
- {{ tx.fee | number }} sat |
-
-
- Fee rate |
-
- {{ tx.feePerVsize | feeRounding }} sat/vB
-
-
-
-
- |
-
-
+
+
+
+
- Effective fee rate |
-
-
- {{ tx.effectiveFeePerVsize | feeRounding }} sat/vB
-
-
-
-
- |
+ Coinbase |
+ {{ tx.vin[0].scriptsig | hex2ascii }} |
-
-
- Virtual size |
- |
-
-
- Locktime |
- |
-
-
- Outputs |
- {{ tx.vout.length }} |
-
-
-
+
+
+
+
+
+
+ OP_RETURN |
+ {{ vout.scriptpubkey_asm | hex2ascii }} |
+
+
+
+
+
diff --git a/frontend/src/app/components/transaction/transaction-preview.component.scss b/frontend/src/app/components/transaction/transaction-preview.component.scss
index a8e2a0acb..4a65dd0d8 100644
--- a/frontend/src/app/components/transaction/transaction-preview.component.scss
+++ b/frontend/src/app/components/transaction/transaction-preview.component.scss
@@ -69,7 +69,67 @@
}
.tx-link {
- display: inline-block;
+ display: inline;
font-size: 28px;
margin-bottom: 6px;
}
+
+.graph-wrapper {
+ position: relative;
+ background: #181b2d;
+ padding: 10px;
+ padding-bottom: 0;
+
+ .above-bow {
+ position: absolute;
+ top: 20px;
+ left: 0;
+ right: 0;
+ margin: auto;
+ text-align: center;
+
+ .field {
+ font-size: 32px;
+ margin: 0;
+
+ ::ng-deep .symbol {
+ font-size: 24px;
+ }
+ }
+ }
+
+ .overlaid {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ text-align: left;
+ font-size: 28px;
+ max-width: 90%;
+ margin: auto;
+ overflow: hidden;
+
+ .opreturns {
+ width: auto;
+ margin: auto;
+ table-layout: auto;
+ background: #2d3348af;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+
+ td {
+ padding: 10px 10px;
+
+ &.message {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts
index 05ce623fb..15a881446 100644
--- a/frontend/src/app/components/transaction/transaction-preview.component.ts
+++ b/frontend/src/app/components/transaction/transaction-preview.component.ts
@@ -7,10 +7,9 @@ import {
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 { of, merge, Subscription, Observable, Subject, 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';
@@ -30,13 +29,16 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
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();
+ isLiquid = false;
+ totalValue: number;
+ opReturns: Vout[];
+ extraData: 'none' | 'coinbase' | 'opreturn';
constructor(
private route: ActivatedRoute,
@@ -49,7 +51,12 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
ngOnInit() {
this.stateService.networkChanged$.subscribe(
- (network) => (this.network = network)
+ (network) => {
+ this.network = network;
+ if (this.network === 'liquid' || this.network == 'liquidtestnet') {
+ this.isLiquid = true;
+ }
+ }
);
this.fetchCpfpSubscription = this.fetchCpfp$
@@ -152,12 +159,9 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
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();
- }
+ this.totalValue = this.tx.vout.reduce((acc, v) => v.value + acc, 0);
+ this.opReturns = this.getOpReturns(this.tx);
+ this.extraData = this.chooseExtraData();
if (!this.tx.status.confirmed) {
if (tx.cpfpChecked) {
@@ -181,26 +185,10 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
);
}
- 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;
}
@@ -217,6 +205,20 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
return tx.vout.map((v: Vout) => v.value || 0).reduce((a: number, b: number) => a + b);
}
+ getOpReturns(tx: Transaction): Vout[] {
+ return tx.vout.filter((v) => v.scriptpubkey_type === 'op_return' && v.scriptpubkey_asm !== 'OP_RETURN');
+ }
+
+ chooseExtraData(): 'none' | 'opreturn' | 'coinbase' {
+ if (this.isCoinbase(this.tx)) {
+ return 'coinbase';
+ } else if (this.opReturns?.length) {
+ return 'opreturn';
+ } else {
+ return 'none';
+ }
+ }
+
ngOnDestroy() {
this.subscription.unsubscribe();
this.fetchCpfpSubscription.unsubscribe();
diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html
new file mode 100644
index 000000000..d3452b5a9
--- /dev/null
+++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html
@@ -0,0 +1,36 @@
+
diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss
new file mode 100644
index 000000000..ea45e4495
--- /dev/null
+++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss
@@ -0,0 +1,6 @@
+.bowtie {
+ .line {
+ stroke: white;
+ fill: none;
+ }
+}
diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts
new file mode 100644
index 000000000..68fdabeae
--- /dev/null
+++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts
@@ -0,0 +1,145 @@
+import { Component, OnInit, Input, OnChanges } from '@angular/core';
+import { Transaction } from '../../interfaces/electrs.interface';
+
+interface SvgLine {
+ path: string;
+ style: string;
+ class?: string;
+}
+
+@Component({
+ selector: 'tx-bowtie-graph',
+ templateUrl: './tx-bowtie-graph.component.html',
+ styleUrls: ['./tx-bowtie-graph.component.scss'],
+})
+export class TxBowtieGraphComponent implements OnInit, OnChanges {
+ @Input() tx: Transaction;
+ @Input() isLiquid: boolean = false;
+ @Input() width = 1200;
+ @Input() height = 600;
+ @Input() combinedWeight = 100;
+ @Input() minWeight = 2; //
+ @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen.
+
+ inputs: SvgLine[];
+ outputs: SvgLine[];
+ middle: SvgLine;
+
+ ngOnInit(): void {
+ this.initGraph();
+ }
+
+ ngOnChanges(): void {
+ this.initGraph();
+ }
+
+ initGraph(): void {
+ const totalValue = this.calcTotalValue(this.tx);
+ const voutWithFee = this.tx.vout.map(v => { return { type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', value: v?.value }; });
+
+ if (this.tx.fee && !this.isLiquid) {
+ voutWithFee.unshift({ type: 'fee', value: this.tx.fee });
+ }
+
+ this.inputs = this.initLines('in', this.tx.vin.map(v => { return {type: 'input', value: v?.prevout?.value }; }), totalValue, this.maxStrands);
+ this.outputs = this.initLines('out', voutWithFee, totalValue, this.maxStrands);
+
+ this.middle = {
+ path: `M ${(this.width / 2) - 50} ${(this.height / 2) + 0.5} L ${(this.width / 2) + 50} ${(this.height / 2) + 0.5}`,
+ style: `stroke-width: ${this.combinedWeight + 0.5}`
+ };
+ }
+
+ calcTotalValue(tx: Transaction): number {
+ const totalOutput = this.tx.vout.reduce((acc, v) => (v.value == null ? 0 : v.value) + acc, 0);
+ // simple sum of outputs + fee for bitcoin
+ if (!this.isLiquid) {
+ return this.tx.fee ? totalOutput + this.tx.fee : totalOutput;
+ } else {
+ const totalInput = this.tx.vin.reduce((acc, v) => (v?.prevout?.value == null ? 0 : v.prevout.value) + acc, 0);
+ const confidentialInputCount = this.tx.vin.reduce((acc, v) => acc + (v?.prevout?.value == null ? 1 : 0), 0);
+ const confidentialOutputCount = this.tx.vout.reduce((acc, v) => acc + (v.value == null ? 1 : 0), 0);
+
+ // if there are unknowns on both sides, the total is indeterminate, so we'll just fudge it
+ if (confidentialInputCount && confidentialOutputCount) {
+ const knownInputCount = (tx.vin.length - confidentialInputCount) || 1;
+ const knownOutputCount = (tx.vout.length - confidentialOutputCount) || 1;
+ // assume confidential inputs/outputs have the same average value as the known ones
+ const adjustedTotalInput = totalInput + ((totalInput / knownInputCount) * confidentialInputCount);
+ const adjustedTotalOutput = totalOutput + ((totalOutput / knownOutputCount) * confidentialOutputCount);
+ return Math.max(adjustedTotalInput, adjustedTotalOutput) || 1;
+ } else {
+ // otherwise knowing the actual total of one side suffices
+ return Math.max(totalInput, totalOutput) || 1;
+ }
+ }
+ }
+
+ initLines(side: 'in' | 'out', xputs: { type: string, value: number | void }[], total: number, maxVisibleStrands: number): SvgLine[] {
+ const lines = [];
+ let unknownCount = 0;
+ let unknownTotal = total == null ? this.combinedWeight : total;
+ xputs.forEach(put => {
+ if (put.value == null) {
+ unknownCount++;
+ } else {
+ unknownTotal -= put.value as number;
+ }
+ });
+ const unknownShare = unknownTotal / unknownCount;
+
+ // conceptual weights
+ const weights = xputs.map((put): number => this.combinedWeight * (put.value == null ? unknownShare : put.value as number) / total);
+ // actual displayed line thicknesses
+ const minWeights = weights.map((w) => Math.max(this.minWeight - 1, w) + 1);
+ const visibleStrands = Math.min(maxVisibleStrands, xputs.length);
+ const visibleWeight = minWeights.slice(0, visibleStrands).reduce((acc, v) => v + acc, 0);
+ const gaps = visibleStrands - 1;
+
+ const innerTop = (this.height / 2) - (this.combinedWeight / 2);
+ const innerBottom = innerTop + this.combinedWeight;
+ // tracks the visual bottom of the endpoints of the previous line
+ let lastOuter = 0;
+ let lastInner = innerTop;
+ // gap between strands
+ const spacing = (this.height - visibleWeight) / gaps;
+
+ for (let i = 0; i < xputs.length; i++) {
+ const weight = weights[i];
+ const minWeight = minWeights[i];
+ // set the vertical position of the (center of the) outer side of the line
+ let outer = lastOuter + (minWeight / 2);
+ const inner = Math.min(innerBottom + (minWeight / 2), Math.max(innerTop + (minWeight / 2), lastInner + (weight / 2)));
+
+ // special case to center single input/outputs
+ if (xputs.length === 1) {
+ outer = (this.height / 2);
+ }
+
+ lastOuter += minWeight + spacing;
+ lastInner += weight;
+ lines.push({
+ path: this.makePath(side, outer, inner, minWeight),
+ style: this.makeStyle(minWeight, xputs[i].type),
+ class: xputs[i].type
+ });
+ }
+
+ return lines;
+ }
+
+ makePath(side: 'in' | 'out', outer: number, inner: number, weight: number): string {
+ const start = side === 'in' ? (weight * 0.5) : this.width - (weight * 0.5);
+ const center = this.width / 2 + (side === 'in' ? -45 : 45 );
+ const midpoint = (start + center) / 2;
+ return `M ${start} ${outer} C ${midpoint} ${outer}, ${midpoint} ${inner}, ${center} ${inner}`;
+ }
+
+ makeStyle(minWeight, type): string {
+ if (type === 'fee') {
+ return `stroke-width: ${minWeight}; stroke: url(#fee-gradient)`;
+ } else {
+ return `stroke-width: ${minWeight}`;
+ }
+ }
+}
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index f9de57834..c340fb50b 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -63,6 +63,7 @@ import { StatusViewComponent } from '../components/status-view/status-view.compo
import { FeesBoxComponent } from '../components/fees-box/fees-box.component';
import { DifficultyComponent } from '../components/difficulty/difficulty.component';
import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component';
+import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component';
import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component';
import { TrademarkPolicyComponent } from '../components/trademark-policy/trademark-policy.component';
import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component';
@@ -138,6 +139,7 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati
StatusViewComponent,
FeesBoxComponent,
DifficultyComponent,
+ TxBowtieGraphComponent,
TermsOfServiceComponent,
PrivacyPolicyComponent,
TrademarkPolicyComponent,
@@ -242,6 +244,7 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati
StatusViewComponent,
FeesBoxComponent,
DifficultyComponent,
+ TxBowtieGraphComponent,
TermsOfServiceComponent,
PrivacyPolicyComponent,
TrademarkPolicyComponent,