diff --git a/backend/src/logger.ts b/backend/src/logger.ts
index ea7e8cd3d..63774d513 100644
--- a/backend/src/logger.ts
+++ b/backend/src/logger.ts
@@ -74,7 +74,7 @@ class Logger {
private getNetwork(): string {
if (config.LIGHTNING.ENABLED) {
- return 'lightning';
+ return config.MEMPOOL.NETWORK === 'mainnet' ? 'lightning' : `${config.MEMPOOL.NETWORK}-lightning`;
}
if (config.BISQ.ENABLED) {
return 'bisq';
diff --git a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts
index e71b12375..dd958a6e3 100644
--- a/backend/src/tasks/lightning/sync-tasks/stats-importer.ts
+++ b/backend/src/tasks/lightning/sync-tasks/stats-importer.ts
@@ -20,6 +20,10 @@ class LightningStatsImporter {
logger.info('Caching funding txs for currently existing channels');
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
+ if (config.MEMPOOL.NETWORK !== 'mainnet' || config.DATABASE.ENABLED === false) {
+ return;
+ }
+
await this.$importHistoricalLightningStats();
await this.$cleanupIncorrectSnapshot();
}
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 @@
-
-
-
-
- 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
-
-
-
-
+
+
+
+
+
+
+
+
+ {{ 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/lightning/channel/channel-box/channel-box.component.html b/frontend/src/app/lightning/channel/channel-box/channel-box.component.html
index 382fffd47..37918b8df 100644
--- a/frontend/src/app/lightning/channel/channel-box/channel-box.component.html
+++ b/frontend/src/app/lightning/channel/channel-box/channel-box.component.html
@@ -4,7 +4,7 @@
{{ channel.public_key | shortenString : 12 }}
-
+