diff --git a/frontend/src/app/components/transaction/transaction-preview.component.html b/frontend/src/app/components/transaction/transaction-preview.component.html index f9f62c417..44be1dcfa 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.html +++ b/frontend/src/app/components/transaction/transaction-preview.component.html @@ -2,6 +2,9 @@

Transaction

+ + {{txId.slice(0,-4)}}{{txId.slice(-4)}} +
@@ -13,104 +16,50 @@
- - {{ txId }} - +
+ + Confidential + + + + + ‎{{ (tx.status.confirmed ? tx.status.block_time : transactionTime) * 1000 | date:'yyyy-MM-dd HH:mm' }} + Fee {{ tx.fee | number }} sat +
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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
+
+ +
+

+ + +

+

+ {{ 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..7aefe0063 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.scss +++ b/frontend/src/app/components/transaction/transaction-preview.component.scss @@ -10,26 +10,10 @@ 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; @@ -46,8 +30,9 @@ display: flex; flex-direction: row; justify-content: space-between; - align-items: center; - margin-bottom: 10px; + align-items: baseline; + margin-bottom: 2px; + max-width: 100%; h1 { font-size: 52px; @@ -58,6 +43,43 @@ .features { font-size: 24px; } + + & > * { + flex-grow: 0; + flex-shrink: 0; + } + + .tx-link { + flex-grow: 1; + flex-shrink: 1; + margin: 0 1em; + overflow: hidden; + white-space: nowrap; + display: flex; + flex-direction: row; + align-items: baseline; + + .truncated { + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + margin-right: -2px; + } + + .last-four { + flex-shrink: 0; + flex-grow: 0; + } + } + + .features { + align-self: center; + } +} + +.top-data { + font-size: 28px; } .table { @@ -68,8 +90,76 @@ } } +.field { + font-size: 32px; + margin: 0; + + ::ng-deep .symbol { + font-size: 24px; + } + + .label { + color: #ffffff66; + } + + &.pair > *:first-child { + margin-right: 1em; + } +} + .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; + } + + .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..d30789f6b 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'; @@ -37,6 +36,10 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { showCpfpDetails = false; fetchCpfp$ = new Subject(); liquidUnblinding = new LiquidUnblinding(); + isLiquid = false; + totalValue: number; + opReturns: Vout[]; + extraData: 'none' | 'coinbase' | 'opreturn'; constructor( private route: ActivatedRoute, @@ -49,7 +52,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,6 +160,9 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { this.tx.feePerVsize = tx.fee / (tx.weight / 4); this.isLoadingTx = false; this.error = undefined; + this.totalValue = this.tx.vout.reduce((acc, v) => v.value + acc, 0); + this.opReturns = this.getOpReturns(this.tx); + this.extraData = this.chooseExtraData(); if (!tx.status.confirmed && tx.firstSeen) { this.transactionTime = tx.firstSeen; @@ -217,6 +228,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..c4771c58c --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..6de41b95f --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss @@ -0,0 +1,15 @@ +.bowtie { + .line { + fill: none; + + &.input { + stroke: url(#input-gradient); + } + &.output { + stroke: url(#output-gradient); + } + &.fee { + stroke: url(#fee-gradient); + } + } +} 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..427a282a9 --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts @@ -0,0 +1,169 @@ +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() network: string; + @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; + isLiquid: boolean = false; + + gradientColors = { + '': ['#9339f4', '#105fb0'], + bisq: ['#9339f4', '#105fb0'], + // liquid: ['#116761', '#183550'], + liquid: ['#09a197', '#0f62af'], + // 'liquidtestnet': ['#494a4a', '#272e46'], + 'liquidtestnet': ['#d2d2d2', '#979797'], + // testnet: ['#1d486f', '#183550'], + testnet: ['#4edf77', '#10a0af'], + // signet: ['#6f1d5d', '#471850'], + signet: ['#d24fc8', '#a84fd2'], + }; + + gradient: string[] = ['#105fb0', '#105fb0']; + + ngOnInit(): void { + this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet'); + this.gradient = this.gradientColors[this.network]; + this.initGraph(); + } + + ngOnChanges(): void { + this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet'); + this.gradient = this.gradientColors[this.network]; + 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}; stroke: ${this.gradient[1]}` + }; + } + + 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; + // correct for svg horizontal gradient bug + if (Math.round(outer) === Math.round(inner)) { + outer -= 1; + } + 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,