Merge branch 'master' into simon/channel-closing-type-header

This commit is contained in:
wiz
2022-08-28 17:06:03 +02:00
committed by GitHub
41 changed files with 894 additions and 266 deletions

View File

@@ -2,6 +2,9 @@
<div class="page-title">
<h1 i18n="shared.transaction">Transaction</h1>
<a class="tx-link" [routerLink]="['/tx/' | relativeUrl, txId]">
<span class="truncated">{{txId.slice(0,-4)}}</span><span class="last-four">{{txId.slice(-4)}}</span>
</a>
<div *ngIf="network !== 'liquid' && network !== 'liquidtestnet'" class="features">
<app-tx-features [tx]="tx"></app-tx-features>
<span *ngIf="cpfpInfo && cpfpInfo.bestDescendant" class="badge badge-primary mr-1">
@@ -13,104 +16,50 @@
</div>
</div>
<a [routerLink]="['/tx/' | relativeUrl, txId]" class="tx-link">
{{ txId }}
</a>
<div class="top-data row">
<span class="field col-sm-4 text-left">
<ng-template [ngIf]="isLiquid && haveBlindedOutputValues(tx)" [ngIfElse]="defaultAmount" i18n="shared.confidential">Confidential</ng-template>
<ng-template #defaultAmount>
<app-amount [satoshis]="totalValue"></app-amount>
</ng-template>
</span>
<span class="field col-sm-4 text-center">&lrm;{{ (tx.status.confirmed ? tx.status.block_time : transactionTime) * 1000 | date:'yyyy-MM-dd HH:mm' }}</span>
<span class="field col-sm-4 text-right"><span class="label" i18n="transaction.fee|Transaction fee">Fee </span>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span>
</div>
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr *ngIf="tx.status.confirmed; else firstSeen">
<td i18n="block.timestamp">Timestamp</td>
<td>
&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td>
</tr>
<ng-template #firstSeen>
<tr>
<td i18n="transaction.first-seen|Transaction first seen">First seen</td>
<td *ngIf="transactionTime > 0; else notSeen">
&lrm;{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td>
<ng-template #notSeen>
<td>?</td>
</ng-template>
</tr>
</ng-template>
<tr>
<td class="td-width" i18n="dashboard.latest-transactions.amount">Amount</td>
<td>
<ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && haveBlindedOutputValues(tx)" [ngIfElse]="defaultAmount" i18n="shared.confidential">Confidential</ng-template>
<ng-template #defaultAmount>
<app-amount [satoshis]="getTotalTxOutput(tx)"></app-amount>
</ng-template>
</td>
</tr>
<tr>
<td i18n="block.size">Size</td>
<td [innerHTML]="'&lrm;' + (tx.size | bytes: 2)"></td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (tx.weight | wuBytes: 2)"></td>
</tr>
<tr>
<td i18n="transaction.inputs">Inputs</td>
<td *ngIf="!isCoinbase(tx); else coinbaseInputs">{{ tx.vin.length }}</td>
<ng-template #coinbaseInputs>
<td i18n="transactions-list.coinbase">Coinbase</td>
</ng-template>
</tr>
</tbody>
</table>
<div class="row graph-wrapper">
<tx-bowtie-graph [tx]="tx" [width]="1112" [height]="346" [network]="network"></tx-bowtie-graph>
<div class="above-bow">
<p class="field pair">
<span [innerHTML]="'&lrm;' + (tx.size | bytes: 2)"></span>
<span [innerHTML]="'&lrm;' + (tx.weight | wuBytes: 2)"></span>
</p>
<p class="field" *ngIf="!isCoinbase(tx)">
{{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
</p>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
</tr>
<tr *ngIf="!cpfpInfo || (!cpfpInfo.bestDescendant && !cpfpInfo.ancestors.length); else cpfpFee">
<td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td>
{{ tx.feePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
<ng-template [ngIf]="tx.status.confirmed">
&nbsp;
<app-tx-fee-rating *ngIf="tx.fee && ((cpfpInfo && !cpfpInfo.bestDescendant && !cpfpInfo.ancestors.length) || !cpfpInfo)" [tx]="tx"></app-tx-fee-rating>
</ng-template>
</td>
</tr>
<ng-template #cpfpFee>
<div class="overlaid">
<ng-container [ngSwitch]="extraData">
<table class="opreturns" *ngSwitchCase="'coinbase'">
<tbody>
<tr>
<td i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
<td>
<div class="effective-fee-container">
{{ tx.effectiveFeePerVsize | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
<ng-template [ngIf]="tx.status.confirmed">
<app-tx-fee-rating class="d-none d-lg-inline ml-2" *ngIf="tx.fee" [tx]="tx"></app-tx-fee-rating>
</ng-template>
</div>
</td>
<td class="label">Coinbase</td>
<td class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</td>
</tr>
</ng-template>
<tr>
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (tx.weight / 4 | vbytes: 2)"></td>
</tr>
<tr>
<td i18n="transaction.locktime">Locktime</td>
<td [innerHTML]="'&lrm;' + (tx.locktime | number)"></td>
</tr>
<tr>
<td i18n="transaction.outputs">Outputs</td>
<td>{{ tx.vout.length }}</td>
</tr>
</tbody>
</table>
</tbody>
</table>
<table class="opreturns" *ngSwitchCase="'opreturn'">
<tbody>
<ng-container *ngFor="let vout of opReturns.slice(0,3)">
<tr>
<td class="label">OP_RETURN</td>
<td *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</td>
</tr>
</ng-container>
</tbody>
</table>
</ng-container>
</div>
</div>
</div>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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<string>();
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();

View File

@@ -20,7 +20,7 @@
<div class="col">
<table class="table table-borderless smaller-text table-sm table-tx-vin">
<tbody>
<ng-template ngFor let-vin [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
<ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{
'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded,
'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== ''
@@ -77,7 +77,7 @@
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
<div>
<app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels>
<app-address-labels [vin]="vin" [channel]="tx._channels && tx._channels.inputs[vindex] || null"></app-address-labels>
</div>
</ng-template>
</ng-container>
@@ -172,7 +172,7 @@
</span>
</a>
<div>
<app-address-labels [vout]="vout" [channel]="channels && channels.outputs[i] && channels.outputs[i].transaction_vout === vindex ? channels.outputs[i] : null"></app-address-labels>
<app-address-labels [vout]="vout" [channel]="tx._channels && tx._channels.outputs[vindex] ? tx._channels.outputs[vindex] : null"></app-address-labels>
</div>
<ng-template #scriptpubkey_type>
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
@@ -212,15 +212,15 @@
</ng-template>
</td>
<td class="arrow-td">
<span *ngIf="!outspends[i] || vout.scriptpubkey_type === 'op_return' || vout.scriptpubkey_type === 'fee' ; else outspend" class="grey">
<span *ngIf="!tx._outspends || vout.scriptpubkey_type === 'op_return' || vout.scriptpubkey_type === 'fee' ; else outspend" class="grey">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span>
<ng-template #outspend>
<span *ngIf="!outspends[i][vindex] || !outspends[i][vindex].spent; else spent" class="green">
<span *ngIf="!tx._outspends[vindex] || !tx._outspends[vindex].spent; else spent" class="green">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span>
<ng-template #spent>
<a *ngIf="outspends[i][vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, outspends[i][vindex].txid]" class="red">
<a *ngIf="tx._outspends[vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, tx._outspends[vindex].txid]" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a>
<ng-template #outputNoTxId>

View File

@@ -27,7 +27,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() outputIndex: number;
@Input() address: string = '';
@Input() rowLimit = 12;
@Input() channels: { inputs: any[], outputs: any[] };
@Output() loadMore = new EventEmitter();
@@ -36,8 +35,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(false);
outspends: Outspend[][] = [];
assetsMinimal: any;
transactionsLength: number = 0;
constructor(
public stateService: StateService,
@@ -47,7 +46,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
private ref: ChangeDetectorRef,
) { }
ngOnInit() {
ngOnInit(): void {
this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block));
this.stateService.networkChanged$.subscribe((network) => this.network = network);
@@ -62,14 +61,17 @@ export class TransactionsListComponent implements OnInit, OnChanges {
.pipe(
switchMap((txIds) => this.apiService.getOutspendsBatched$(txIds)),
tap((outspends: Outspend[][]) => {
this.outspends = this.outspends.concat(outspends);
const transactions = this.transactions.filter((tx) => !tx._outspends);
outspends.forEach((outspend, i) => {
transactions[i]._outspends = outspend;
});
}),
),
this.stateService.utxoSpent$
.pipe(
tap((utxoSpent) => {
for (const i in utxoSpent) {
this.outspends[0][i] = {
this.transactions[0]._outspends[i] = {
spent: true,
txid: utxoSpent[i].txid,
vin: utxoSpent[i].vin,
@@ -81,21 +83,23 @@ export class TransactionsListComponent implements OnInit, OnChanges {
.pipe(
filter(() => this.stateService.env.LIGHTNING),
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
map((channels) => {
this.channels = channels;
tap((channels) => {
const transactions = this.transactions.filter((tx) => !tx._channels);
channels.forEach((channel, i) => {
transactions[i]._channels = channel;
});
}),
)
,
).subscribe(() => this.ref.markForCheck());
}
ngOnChanges() {
ngOnChanges(): void {
if (!this.transactions || !this.transactions.length) {
return;
}
if (this.paginated) {
this.outspends = [];
}
this.transactionsLength = this.transactions.length;
if (this.outputIndex) {
setTimeout(() => {
const assetBoxElements = document.getElementsByClassName('assetBox');
@@ -126,14 +130,19 @@ export class TransactionsListComponent implements OnInit, OnChanges {
tx['addressValue'] = addressIn - addressOut;
}
});
const txIds = this.transactions.map((tx) => tx.txid);
this.refreshOutspends$.next(txIds);
if (!this.channels) {
this.refreshChannels$.next(txIds);
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);
if (txIds.length) {
this.refreshChannels$.next(txIds);
}
}
}
onScroll() {
onScroll(): void {
const scrollHeight = document.body.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
if (scrollHeight > 0){
@@ -148,11 +157,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
return tx.vout.some((v: any) => v.value === undefined);
}
getTotalTxOutput(tx: Transaction) {
getTotalTxOutput(tx: Transaction): number {
return tx.vout.map((v: Vout) => v.value || 0).reduce((a: number, b: number) => a + b);
}
switchCurrency() {
switchCurrency(): void {
if (this.network === 'liquid' || this.network === 'liquidtestnet') {
return;
}
@@ -164,7 +173,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
return tx.txid + tx.status.confirmed;
}
trackByIndexFn(index: number) {
trackByIndexFn(index: number): number {
return index;
}
@@ -177,7 +186,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
return Math.pow(base, exponent);
}
toggleDetails() {
toggleDetails(): void {
if (this.showDetails$.value === true) {
this.showDetails$.next(false);
} else {
@@ -185,7 +194,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
loadMoreInputs(tx: Transaction) {
loadMoreInputs(tx: Transaction): void {
tx['@vinLimit'] = false;
this.electrsApiService.getTransaction$(tx.txid)
@@ -196,7 +205,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
});
}
ngOnDestroy() {
ngOnDestroy(): void {
this.outspendsSubscription.unsubscribe();
}
}

View File

@@ -0,0 +1,44 @@
<svg *ngIf="inputs && outputs" class="bowtie" [attr.height]="(height + 10) + 'px'" [attr.width]="width + 'px'">
<defs>
<marker id="input-arrow" viewBox="-5 -5 10 10"
refX="0" refY="0"
markerUnits="strokeWidth"
markerWidth="1.5" markerHeight="1"
orient="auto">
<path d="M -5 -5 L 0 0 L -5 5 L 1 5 L 1 -5 Z" stroke-width="0" [attr.fill]="gradient[0]"/>
</marker>
<marker id="output-arrow" viewBox="-5 -5 10 10"
refX="0" refY="0"
markerUnits="strokeWidth"
markerWidth="1.5" markerHeight="1"
orient="auto">
<path d="M 1 -5 L 0 -5 L -5 0 L 0 5 L 1 5 Z" stroke-width="0" [attr.fill]="gradient[0]"/>
</marker>
<marker id="fee-arrow" viewBox="-5 -5 10 10"
refX="0" refY="0"
markerUnits="strokeWidth"
markerWidth="1.5" markerHeight="1"
orient="auto">
</marker>
<linearGradient id="input-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[0]" />
<stop offset="100%" [attr.stop-color]="gradient[1]" />
</linearGradient>
<linearGradient id="output-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[1]" />
<stop offset="100%" [attr.stop-color]="gradient[0]" />
</linearGradient>
<linearGradient id="fee-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[1]" />
<stop offset="50%" [attr.stop-color]="gradient[1]" />
<stop offset="100%" stop-color="transparent" />
</linearGradient>
</defs>
<path [attr.d]="middle.path" class="line middle" [style]="middle.style"/>
<ng-container *ngFor="let input of inputs">
<path [attr.d]="input.path" class="line {{input.class}}" [style]="input.style" attr.marker-start="url(#{{input.class}}-arrow)"/>
</ng-container>
<ng-container *ngFor="let output of outputs">
<path [attr.d]="output.path" class="line {{output.class}}" [style]="output.style" attr.marker-start="url(#{{output.class}}-arrow)" />
</ng-container>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,15 @@
.bowtie {
.line {
fill: none;
&.input {
stroke: url(#input-gradient);
}
&.output {
stroke: url(#output-gradient);
}
&.fee {
stroke: url(#fee-gradient);
}
}
}

View File

@@ -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}`;
}
}
}

View File

@@ -1,3 +1,5 @@
import { IChannel } from './node-api.interface';
export interface Transaction {
txid: string;
version: number;
@@ -19,6 +21,13 @@ export interface Transaction {
deleteAfter?: number;
_unblinded?: any;
_deduced?: boolean;
_outspends?: Outspend[];
_channels?: TransactionChannels;
}
export interface TransactionChannels {
inputs: { [vin: number]: IChannel };
outputs: { [vout: number]: IChannel };
}
interface Ancestor {

View File

@@ -189,3 +189,35 @@ export interface IOldestNodes {
city?: any,
country?: any,
}
export interface IChannel {
id: number;
short_id: string;
capacity: number;
transaction_id: string;
transaction_vout: number;
closing_transaction_id: string;
closing_reason: string;
updated_at: string;
created: string;
status: number;
node_left: Node,
node_right: Node,
}
export interface INode {
alias: string;
public_key: string;
channels: number;
capacity: number;
base_fee_mtokens: number;
cltv_delta: number;
fee_rate: number;
is_disabled: boolean;
max_htlc_mtokens: number;
min_htlc_mtokens: number;
updated_at: string;
longitude: number;
latitude: number;
}

View File

@@ -4,7 +4,7 @@
<a [routerLink]="['/lightning/node' | relativeUrl, channel.public_key]" >
{{ channel.public_key | shortenString : 12 }}
</a>
<app-clipboard [text]="channel.node1_public_key"></app-clipboard>
<app-clipboard [text]="channel.public_key"></app-clipboard>
</div>
<div class="box-right">
<div class="second-line">{{ channel.channels }} channels</div>
@@ -51,4 +51,4 @@
</div>
</div>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>

View File

@@ -58,7 +58,7 @@
</table>
</div>
<div class="col-md map-col">
<app-nodes-channels-map *ngIf="!error" [style]="'channelpage'" [channel]="channelGeo" [fitContainer]="true" (readyEvent)="onMapReady()"></app-nodes-channels-map>
<app-nodes-channels-map *ngIf="!error" [style]="'channelpage'" [channel]="channelGeo" [fitContainer]="true" [placeholder]="true" (readyEvent)="onMapReady()"></app-nodes-channels-map>
</div>
</div>
</div>

View File

@@ -66,13 +66,13 @@
<ng-container *ngIf="transactions$ | async as transactions">
<ng-template [ngIf]="transactions[0]">
<h3>Opening transaction</h3>
<app-transactions-list [transactions]="[transactions[0]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [], outputs: [channel] }"></app-transactions-list>
<app-transactions-list [transactions]="[transactions[0]]" [showConfirmations]="true" [rowLimit]="5"></app-transactions-list>
</ng-template>
<ng-template [ngIf]="transactions[1]">
<div class="closing-header">
<h3 style="margin: 0;">Closing transaction</h3>&nbsp;&nbsp;<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</div>
<app-transactions-list [transactions]="[transactions[1]]" [showConfirmations]="true" [rowLimit]="5" [channels]="{ inputs: [channel], outputs: [] }"></app-transactions-list>
<app-transactions-list [transactions]="[transactions[1]]" [showConfirmations]="true" [rowLimit]="5"></app-transactions-list>
</ng-template>
</ng-container>

View File

@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { forkJoin, Observable, of, share, zip } from 'rxjs';
import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators';
import { IChannel } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service';
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { SeoService } from 'src/app/services/seo.service';
@@ -62,10 +63,15 @@ export class ChannelComponent implements OnInit {
);
this.transactions$ = this.channel$.pipe(
switchMap((data) => {
switchMap((channel: IChannel) => {
return zip([
data.transaction_id ? this.electrsApiService.getTransaction$(data.transaction_id) : of(null),
data.closing_transaction_id ? this.electrsApiService.getTransaction$(data.closing_transaction_id) : of(null),
channel.transaction_id ? this.electrsApiService.getTransaction$(channel.transaction_id) : of(null),
channel.closing_transaction_id ? this.electrsApiService.getTransaction$(channel.closing_transaction_id).pipe(
map((tx) => {
tx._channels = { inputs: {0: channel}, outputs: {}};
return tx;
})
) : of(null),
]);
}),
);

View File

@@ -87,8 +87,6 @@
</ng-template>
<ng-template #skeleton>
<h2 class="float-left" i18n="lightning.channels">Channels</h2>
<table class="table table-borderless">
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
<tbody>

View File

@@ -52,7 +52,7 @@
</table>
</div>
<div class="col-md map-col">
<app-nodes-channels-map *ngIf="!error" [style]="'nodepage'" [publicKey]="node.public_key" [fitContainer]="true" (readyEvent)="onMapReady()"></app-nodes-channels-map>
<app-nodes-channels-map *ngIf="!error" [style]="'nodepage'" [publicKey]="node.public_key" [fitContainer]="true" [placeholder]="true" [hasLocation]="!!node.as_number" (readyEvent)="onMapReady()"></app-nodes-channels-map>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div class="container-xl" *ngIf="(node$ | async) as node">
<h5 class="mb-0" style="color: #ffffff66" i18n="lightning.node">Lightning node</h5>
<div class="title-container mb-2" *ngIf="!error">
<h1 class="mb-0">{{ node.alias }}</h1>
<h1 class="mb-0 text-truncate">{{ node.alias }}</h1>
<span class="tx-link">
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">
{{ node.public_key | shortenString : publicKeySize }}
@@ -131,7 +131,6 @@
<app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
</div>
<h2 i18n="lightning.active-channels-map">Active channels map</h2>
<app-node-channels style="display:block;margin-bottom: 40px" [publicKey]="node.public_key"></app-node-channels>
<div class="d-flex justify-content-between">

View File

@@ -22,6 +22,7 @@ export class NodesChannelsMap implements OnInit {
@Input() channel: any[] = [];
@Input() fitContainer = false;
@Input() hasLocation = true;
@Input() placeholder = false;
@Output() readyEvent = new EventEmitter();
channelsObservable: Observable<any>;
@@ -201,11 +202,26 @@ export class NodesChannelsMap implements OnInit {
prepareChartOptions(nodes, channels) {
let title: object;
if (channels.length === 0) {
if (channels.length === 0 && !this.placeholder) {
this.chartOptions = null;
return;
}
// empty map fallback
if (channels.length === 0 && this.placeholder) {
title = {
textStyle: {
color: 'white',
fontSize: 18
},
text: $localize`No geolocation data available`,
left: 'center',
top: 'center'
};
this.zoom = 1.5;
this.center = [0, 20];
}
this.chartOptions = {
silent: this.style === 'widget',
title: title ?? undefined,

View File

@@ -1,2 +1,9 @@
<div *ngIf="channelsObservable$ | async" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)">
<div *ngIf="channelsObservable$ | async" style="min-height: 455px">
<h2 i18n="lightning.active-channels-map">Active channels map</h2>
<div echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)">
</div>
</div>
<div *ngIf="isLoading" class="text-center loading-spinner">
<div class="spinner-border text-light"></div>
</div>

View File

@@ -0,0 +1,9 @@
.loading-spinner {
min-height: 455px;
z-index: 100;
}
.spinner-border {
position: relative;
top: 225px;
}

View File

@@ -1,8 +1,8 @@
import { formatNumber } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges } from '@angular/core';
import { Router } from '@angular/router';
import { ECharts, EChartsOption, TreemapSeriesOption } from 'echarts';
import { Observable, tap } from 'rxjs';
import { Observable, share, switchMap, tap } from 'rxjs';
import { lerpColor } from 'src/app/shared/graphs.utils';
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
import { LightningApiService } from '../lightning-api.service';
@@ -25,7 +25,7 @@ export class NodeChannels implements OnChanges {
};
channelsObservable$: Observable<any>;
isLoading: true;
isLoading = true;
constructor(
@Inject(LOCALE_ID) public locale: string,
@@ -41,9 +41,20 @@ export class NodeChannels implements OnChanges {
this.channelsObservable$ = this.lightningApiService.getChannelsByNodeId$(this.publicKey, -1, 'active')
.pipe(
tap((response) => {
const biggestCapacity = response.body[0].capacity;
this.prepareChartOptions(response.body.map(channel => {
switchMap((response) => {
this.isLoading = true;
if ((response.body?.length ?? 0) <= 0) {
return [];
}
return [response.body];
}),
tap((body: any[]) => {
if (body.length === 0) {
this.isLoading = false;
return;
}
const biggestCapacity = body[0].capacity;
this.prepareChartOptions(body.map(channel => {
return {
name: channel.node.alias,
value: channel.capacity,
@@ -54,7 +65,9 @@ export class NodeChannels implements OnChanges {
}
};
}));
})
this.isLoading = false;
}),
share(),
);
}

View File

@@ -242,12 +242,12 @@ export class ApiService {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/enterprise/info/` + name);
}
getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> {
getChannelByTxIds$(txIds: string[]): Observable<any[]> {
let params = new HttpParams();
txIds.forEach((txId: string) => {
params = params.append('txId[]', txId);
});
return this.httpClient.get<{ inputs: any[], outputs: any[] }>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels/txids/', { params });
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels/txids/', { params });
}
lightningSearch$(searchText: string): Observable<any[]> {

View File

@@ -153,7 +153,12 @@ export class StateService {
if (this.env.BASE_MODULE !== 'mempool' && this.env.BASE_MODULE !== 'liquid') {
return;
}
const networkMatches = url.match(/^\/(bisq|testnet|liquidtestnet|liquid|signet)/);
// horrible network regex breakdown:
// /^\/ starts with a forward slash...
// (?:[a-z]{2}(?:-[A-Z]{2})?\/)? optional locale prefix (non-capturing)
// (?:preview\/)? optional "preview" prefix (non-capturing)
// (bisq|testnet|liquidtestnet|liquid|signet)/ network string (captured as networkMatches[1])
const networkMatches = url.match(/^\/(?:[a-z]{2}(?:-[A-Z]{2})?\/)?(?:preview\/)?(bisq|testnet|liquidtestnet|liquid|signet)/);
switch (networkMatches && networkMatches[1]) {
case 'liquid':
if (this.network !== 'liquid') {

View File

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