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 }} - +

+ + {{ 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
+
+ +
+

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