diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 5b35fc7b1..5bd40a557 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -208,7 +208,11 @@ [lineLimit]="inOutLimit" [maxStrands]="graphExpanded ? maxInOut : 24" [network]="network" - [tooltip]="true"> + [tooltip]="true" + [inputIndex]="inputIndex" [outputIndex]="outputIndex" + (selectInput)="selectInput($event)" + (selectOutput)="selectOutput($event)" + >
@@ -240,7 +244,7 @@
- +

Details

diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 1db6e8f09..c64c112b1 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -47,6 +47,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { now = new Date().getTime(); timeAvg$: Observable; liquidUnblinding = new LiquidUnblinding(); + inputIndex: number; outputIndex: number; showFlow: boolean = true; graphExpanded: boolean = false; @@ -121,8 +122,15 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { .pipe( switchMap((params: ParamMap) => { const urlMatch = (params.get('id') || '').split(':'); - this.txId = urlMatch[0]; - this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10); + if (urlMatch.length === 2 && urlMatch[1].length === 64) { + this.inputIndex = parseInt(urlMatch[0], 10); + this.outputIndex = null; + this.txId = urlMatch[1]; + } else { + this.txId = urlMatch[0]; + this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10); + this.inputIndex = null; + } this.seoService.setTitle( $localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:` ); @@ -334,6 +342,16 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.graphExpanded = false; } + selectInput(input) { + this.inputIndex = input; + this.outputIndex = null; + } + + selectOutput(output) { + this.outputIndex = output; + this.inputIndex = null; + } + @HostListener('window:resize', ['$event']) setGraphSize(): void { if (this.graphContainer) { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 81c3dce5c..e53c54a7a 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -20,9 +20,9 @@
- + - + @@ -158,7 +158,7 @@
@@ -146,7 +146,7 @@
- + - + @@ -257,7 +257,7 @@ - + diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 8fd81af51..4f3f1cec3 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -24,6 +24,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { @Input() transactionPage = false; @Input() errorUnblinded = false; @Input() paginated = false; + @Input() inputIndex: number; @Input() outputIndex: number; @Input() address: string = ''; @Input() rowLimit = 12; @@ -37,6 +38,8 @@ export class TransactionsListComponent implements OnInit, OnChanges { showDetails$ = new BehaviorSubject(false); assetsMinimal: any; transactionsLength: number = 0; + inputRowLimit: number = 12; + outputRowLimit: number = 12; constructor( public stateService: StateService, @@ -97,50 +100,57 @@ export class TransactionsListComponent implements OnInit, OnChanges { ).subscribe(() => this.ref.markForCheck()); } - ngOnChanges(): void { - if (!this.transactions || !this.transactions.length) { - return; + ngOnChanges(changes): void { + if (changes.inputIndex || changes.outputIndex || changes.rowLimit) { + this.inputRowLimit = Math.max(this.rowLimit, (this.inputIndex || 0) + 3); + this.outputRowLimit = Math.max(this.rowLimit, (this.outputIndex || 0) + 3); + if ((this.inputIndex || this.outputIndex) && !changes.transactions) { + setTimeout(() => { + const assetBoxElements = document.getElementsByClassName('assetBox'); + if (assetBoxElements && assetBoxElements[0]) { + assetBoxElements[0].scrollIntoView({block: "center"}); + } + }, 10); + } } - - this.transactionsLength = this.transactions.length; - if (this.outputIndex) { - setTimeout(() => { - const assetBoxElements = document.getElementsByClassName('assetBox'); - if (assetBoxElements && assetBoxElements[0]) { - assetBoxElements[0].scrollIntoView(); - } - }, 10); - } - - this.transactions.forEach((tx) => { - tx['@voutLimit'] = true; - tx['@vinLimit'] = true; - if (tx['addressValue'] !== undefined) { + if (changes.transactions || changes.address) { + if (!this.transactions || !this.transactions.length) { return; } - if (this.address) { - const addressIn = tx.vout - .filter((v: Vout) => v.scriptpubkey_address === this.address) - .map((v: Vout) => v.value || 0) - .reduce((a: number, b: number) => a + b, 0); + this.transactionsLength = this.transactions.length; - const addressOut = tx.vin - .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) - .map((v: Vin) => v.prevout.value || 0) - .reduce((a: number, b: number) => a + b, 0); - tx['addressValue'] = addressIn - addressOut; - } - }); - const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); - if (txIds.length) { - this.refreshOutspends$.next(txIds); - } - if (this.stateService.env.LIGHTNING) { - const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); + this.transactions.forEach((tx) => { + tx['@voutLimit'] = true; + tx['@vinLimit'] = true; + if (tx['addressValue'] !== undefined) { + return; + } + + if (this.address) { + const addressIn = tx.vout + .filter((v: Vout) => v.scriptpubkey_address === this.address) + .map((v: Vout) => v.value || 0) + .reduce((a: number, b: number) => a + b, 0); + + const addressOut = tx.vin + .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) + .map((v: Vin) => v.prevout.value || 0) + .reduce((a: number, b: number) => a + b, 0); + + tx['addressValue'] = addressIn - addressOut; + } + }); + const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); if (txIds.length) { - this.refreshChannels$.next(txIds); + this.refreshOutspends$.next(txIds); + } + if (this.stateService.env.LIGHTNING) { + const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); + if (txIds.length) { + this.refreshChannels$.next(txIds); + } } } } diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html index 563e6ed00..6872438a0 100644 --- a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html @@ -44,7 +44,7 @@ Output Fee - #{{ line.index }} + #{{ line.index + 1 }}

Confidential

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 index 03056cd53..ced3b5f57 100644 --- 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 @@ -41,6 +41,18 @@ + + + + + + + + + + + + @@ -56,20 +68,24 @@ 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 index 9cacb7d4b..5a71ee421 100644 --- 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 @@ -12,6 +12,17 @@ stroke: url(#fee-gradient); } + &.highlight { + z-index: 8; + cursor: pointer; + &.input { + stroke: url(#input-highlight-gradient); + } + &.output { + stroke: url(#output-highlight-gradient); + } + } + &:hover { z-index: 10; cursor: pointer; 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 index 16e2736f7..9d29500f0 100644 --- 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 @@ -1,5 +1,11 @@ -import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core'; -import { Transaction } from '../../interfaces/electrs.interface'; +import { Component, OnInit, Input, Output, EventEmitter, OnChanges, HostListener } from '@angular/core'; +import { StateService } from '../../services/state.service'; +import { Outspend, Transaction } from '../../interfaces/electrs.interface'; +import { Router } from '@angular/router'; +import { ReplaySubject, merge, Subscription } from 'rxjs'; +import { tap, switchMap } from 'rxjs/operators'; +import { ApiService } from '../../services/api.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; interface SvgLine { path: string; @@ -34,6 +40,11 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { @Input() minWeight = 2; // @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen. @Input() tooltip = false; + @Input() inputIndex: number; + @Input() outputIndex: number; + + @Output() selectInput = new EventEmitter(); + @Output() selectOutput = new EventEmitter(); inputData: Xput[]; outputData: Xput[]; @@ -45,6 +56,10 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { isLiquid: boolean = false; hoverLine: Xput | void = null; tooltipPosition = { x: 0, y: 0 }; + outspends: Outspend[] = []; + + outspendsSubscription: Subscription; + refreshOutspends$: ReplaySubject = new ReplaySubject(); gradientColors = { '': ['#9339f4', '#105fb0'], @@ -61,12 +76,45 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { gradient: string[] = ['#105fb0', '#105fb0']; + constructor( + private router: Router, + private relativeUrlPipe: RelativeUrlPipe, + private stateService: StateService, + private apiService: ApiService, + ) { } + ngOnInit(): void { this.initGraph(); + + this.outspendsSubscription = merge( + this.refreshOutspends$ + .pipe( + switchMap((txid) => this.apiService.getOutspendsBatched$([txid])), + tap((outspends: Outspend[][]) => { + if (!this.tx || !outspends || !outspends.length) { + return; + } + this.outspends = outspends[0]; + }), + ), + this.stateService.utxoSpent$ + .pipe( + tap((utxoSpent) => { + for (const i in utxoSpent) { + this.outspends[i] = { + spent: true, + txid: utxoSpent[i].txid, + vin: utxoSpent[i].vin, + }; + } + }), + ), + ).subscribe(() => {}); } ngOnChanges(): void { this.initGraph(); + this.refreshOutspends$.next(this.tx.txid); } initGraph(): void { @@ -76,11 +124,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6)); const totalValue = this.calcTotalValue(this.tx); - let voutWithFee = this.tx.vout.map(v => { + let voutWithFee = this.tx.vout.map((v, i) => { return { type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', value: v?.value, address: v?.scriptpubkey_address || v?.scriptpubkey_type?.toUpperCase(), + index: i, pegout: v?.pegout?.scriptpubkey_address, confidential: (this.isLiquid && v?.value === undefined), } as Xput; @@ -91,11 +140,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { } const outputCount = voutWithFee.length; - let truncatedInputs = this.tx.vin.map(v => { + let truncatedInputs = this.tx.vin.map((v, i) => { return { type: 'input', value: v?.prevout?.value, address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(), + index: i, coinbase: v?.is_coinbase, pegin: v?.is_pegin, confidential: (this.isLiquid && v?.prevout?.value === undefined), @@ -306,8 +356,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { }; } else { this.hoverLine = { - ...this.outputData[index], - index + ...this.outputData[index] }; } } @@ -315,4 +364,29 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { onBlur(event, side, index): void { this.hoverLine = null; } + + onClick(event, side, index): void { + if (side === 'input') { + const input = this.tx.vin[index]; + if (input && input.txid && input.vout != null) { + this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid + ':' + input.vout], { + queryParamsHandling: 'merge', + fragment: 'flow' + }); + } else { + this.selectInput.emit(index); + } + } else { + const output = this.tx.vout[index]; + const outspend = this.outspends[index]; + if (output && outspend && outspend.spent && outspend.txid) { + this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.vin + ':' + outspend.txid], { + queryParamsHandling: 'merge', + fragment: 'flow' + }); + } else { + this.selectOutput.emit(index); + } + } + } }