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)"
+ >
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 @@
- rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
+ inputRowLimit) ? tx.vin.slice(0, inputRowLimit - 2) : tx.vin.slice(0, inputRowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
@@ -146,7 +146,7 @@
|
- rowLimit && tx['@vinLimit']">
+
inputRowLimit && tx['@vinLimit']">
|
@@ -158,7 +158,7 @@
- rowLimit) ? tx.vout.slice(0, rowLimit - 2) : tx.vout.slice(0, rowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
+ outputRowLimit) ? tx.vout.slice(0, outputRowLimit - 2) : tx.vout.slice(0, outputRowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
-
+
@@ -257,7 +257,7 @@
- rowLimit && tx['@voutLimit'] && !outputIndex">
+
outputRowLimit && tx['@voutLimit']">
|
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);
+ }
+ }
+ }
}