Merge pull request #1976 from mempool/simon/lightning-pr

Lightning
This commit is contained in:
wiz 2022-07-10 15:08:37 +02:00 committed by GitHub
commit b7709ac3d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 4887 additions and 178 deletions

View File

@ -67,6 +67,15 @@
"ENABLED": false,
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
},
"LIGHTNING": {
"ENABLED": false,
"BACKEND": "lnd"
},
"LND": {
"TLS_CERT_PATH": "tls.cert",
"MACAROON_PATH": "admin.macaroon",
"SOCKET": "localhost:10009"
},
"SOCKS5PROXY": {
"ENABLED": false,
"USE_ONION": true,

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "mempool-backend",
"version": "2.4.1-dev",
"version": "2.5.0-dev",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",
@ -34,8 +34,10 @@
"@types/node": "^16.11.41",
"axios": "~0.27.2",
"bitcoinjs-lib": "6.0.1",
"bolt07": "^1.8.1",
"crypto-js": "^4.0.0",
"express": "^4.18.0",
"lightning": "^5.16.3",
"mysql2": "2.3.3",
"node-worker-threads-pool": "^1.5.1",
"socks-proxy-agent": "~7.0.0",

View File

@ -13,6 +13,7 @@ export interface AbstractBitcoinApi {
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$getAddressPrefix(prefix: string): string[];
$sendRawTransaction(rawTransaction: string): Promise<string>;
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
}

View File

@ -130,6 +130,16 @@ class BitcoinApi implements AbstractBitcoinApi {
return this.bitcoindClient.sendRawTransaction(rawTransaction);
}
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
return {
spent: txOut === null,
status: {
confirmed: true,
}
};
}
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
const outSpends: IEsploraApi.Outspend[] = [];
const tx = await this.$getRawTransaction(txId, true, false);
@ -195,7 +205,9 @@ class BitcoinApi implements AbstractBitcoinApi {
sequence: vin.sequence,
txid: vin.txid || '',
vout: vin.vout || 0,
witness: vin.txinwitness,
witness: vin.txinwitness || [],
inner_redeemscript_asm: '',
inner_witnessscript_asm: '',
};
});

View File

@ -25,10 +25,10 @@ export namespace IEsploraApi {
is_coinbase: boolean;
scriptsig: string;
scriptsig_asm: string;
inner_redeemscript_asm?: string;
inner_witnessscript_asm?: string;
inner_redeemscript_asm: string;
inner_witnessscript_asm: string;
sequence: any;
witness?: string[];
witness: string[];
prevout: Vout | null;
// Elements
is_pegin?: boolean;

View File

@ -66,6 +66,11 @@ class ElectrsApi implements AbstractBitcoinApi {
throw new Error('Method not implemented.');
}
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
return axios.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig)
.then((response) => response.data);
}
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
.then((response) => response.data);

View File

@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common';
class DatabaseMigration {
private static currentVersion = 24;
private static currentVersion = 25;
private queryTimeout = 120000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@ -248,6 +248,15 @@ class DatabaseMigration {
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits'));
}
if (databaseSchemaVersion < 25 && isBitcoin === true) {
await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`);
await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats'));
}
} catch (e) {
throw e;
}
@ -569,6 +578,82 @@ class DatabaseMigration {
adjustment float NOT NULL,
PRIMARY KEY (height),
INDEX (time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateLightningStatisticsQuery(): string {
return `CREATE TABLE IF NOT EXISTS lightning_stats (
id int(11) NOT NULL AUTO_INCREMENT,
added datetime NOT NULL,
channel_count int(11) NOT NULL,
node_count int(11) NOT NULL,
total_capacity double unsigned NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateNodesQuery(): string {
return `CREATE TABLE IF NOT EXISTS nodes (
public_key varchar(66) NOT NULL,
first_seen datetime NOT NULL,
updated_at datetime NOT NULL,
alias varchar(200) CHARACTER SET utf8mb4 NOT NULL,
color varchar(200) NOT NULL,
sockets text DEFAULT NULL,
PRIMARY KEY (public_key),
KEY alias (alias(10))
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateChannelsQuery(): string {
return `CREATE TABLE IF NOT EXISTS channels (
id bigint(11) unsigned NOT NULL,
short_id varchar(15) NOT NULL DEFAULT '',
capacity bigint(20) unsigned NOT NULL,
transaction_id varchar(64) NOT NULL,
transaction_vout int(11) NOT NULL,
updated_at datetime DEFAULT NULL,
created datetime DEFAULT NULL,
status int(11) NOT NULL DEFAULT 0,
closing_transaction_id varchar(64) DEFAULT NULL,
closing_date datetime DEFAULT NULL,
closing_reason int(11) DEFAULT NULL,
node1_public_key varchar(66) NOT NULL,
node1_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
node1_cltv_delta int(11) DEFAULT NULL,
node1_fee_rate bigint(11) DEFAULT NULL,
node1_is_disabled tinyint(1) DEFAULT NULL,
node1_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
node1_min_htlc_mtokens bigint(20) DEFAULT NULL,
node1_updated_at datetime DEFAULT NULL,
node2_public_key varchar(66) NOT NULL,
node2_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
node2_cltv_delta int(11) DEFAULT NULL,
node2_fee_rate bigint(11) DEFAULT NULL,
node2_is_disabled tinyint(1) DEFAULT NULL,
node2_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
node2_min_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
node2_updated_at datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY node1_public_key (node1_public_key),
KEY node2_public_key (node2_public_key),
KEY status (status),
KEY short_id (short_id),
KEY transaction_id (transaction_id),
KEY closing_transaction_id (closing_transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateNodesStatsQuery(): string {
return `CREATE TABLE IF NOT EXISTS node_stats (
id int(11) unsigned NOT NULL AUTO_INCREMENT,
public_key varchar(66) NOT NULL DEFAULT '',
added date NOT NULL,
capacity bigint(20) unsigned NOT NULL DEFAULT 0,
channels int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY added (added,public_key),
KEY public_key (public_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}

View File

@ -0,0 +1,164 @@
import logger from '../../logger';
import DB from '../../database';
class ChannelsApi {
public async $getAllChannels(): Promise<any[]> {
try {
const query = `SELECT * FROM channels`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getAllChannels error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $searchChannelsById(search: string): Promise<any[]> {
try {
const searchStripped = search.replace('%', '') + '%';
const query = `SELECT id, short_id, capacity FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`;
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
return rows;
} catch (e) {
logger.err('$searchChannelsById error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsByStatus(status: number): Promise<any[]> {
try {
const query = `SELECT * FROM channels WHERE status = ?`;
const [rows]: any = await DB.query(query, [status]);
return rows;
} catch (e) {
logger.err('$getChannelsByStatus error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getClosedChannelsWithoutReason(): Promise<any[]> {
try {
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason IS NULL AND closing_transaction_id != ''`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getClosedChannelsWithoutReason error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
try {
const query = `SELECT * FROM channels WHERE created IS NULL`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getChannelsWithoutCreatedDate error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannel(id: string): Promise<any> {
try {
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND channels.id = ?`;
const [rows]: any = await DB.query(query, [id]);
if (rows[0]) {
return this.convertChannel(rows[0]);
}
} catch (e) {
logger.err('$getChannel error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsByTransactionId(transactionIds: string[]): Promise<any[]> {
try {
transactionIds = transactionIds.map((id) => '\'' + id + '\'');
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.* FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key WHERE channels.transaction_id IN (${transactionIds.join(', ')}) OR channels.closing_transaction_id IN (${transactionIds.join(', ')})`;
const [rows]: any = await DB.query(query);
const channels = rows.map((row) => this.convertChannel(row));
return channels;
} catch (e) {
logger.err('$getChannelByTransactionId error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsForNode(public_key: string, index: number, length: number, status: string): Promise<any[]> {
try {
// Default active and inactive channels
let statusQuery = '< 2';
// Closed channels only
if (status === 'closed') {
statusQuery = '= 2';
}
const query = `SELECT n1.alias AS alias_left, n2.alias AS alias_right, channels.*, ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right FROM channels LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key WHERE (ns1.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node1_public_key) AND ns2.id = (SELECT MAX(id) FROM node_stats WHERE public_key = channels.node2_public_key)) AND (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery} ORDER BY channels.capacity DESC LIMIT ?, ?`;
const [rows]: any = await DB.query(query, [public_key, public_key, index, length]);
const channels = rows.map((row) => this.convertChannel(row));
return channels;
} catch (e) {
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getChannelsCountForNode(public_key: string, status: string): Promise<any> {
try {
// Default active and inactive channels
let statusQuery = '< 2';
// Closed channels only
if (status === 'closed') {
statusQuery = '= 2';
}
const query = `SELECT COUNT(*) AS count FROM channels WHERE (node1_public_key = ? OR node2_public_key = ?) AND status ${statusQuery}`;
const [rows]: any = await DB.query(query, [public_key, public_key]);
return rows[0]['count'];
} catch (e) {
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
private convertChannel(channel: any): any {
return {
'id': channel.id,
'short_id': channel.short_id,
'capacity': channel.capacity,
'transaction_id': channel.transaction_id,
'transaction_vout': channel.transaction_vout,
'closing_transaction_id': channel.closing_transaction_id,
'closing_reason': channel.closing_reason,
'updated_at': channel.updated_at,
'created': channel.created,
'status': channel.status,
'node_left': {
'alias': channel.alias_left,
'public_key': channel.node1_public_key,
'channels': channel.channels_left,
'capacity': channel.capacity_left,
'base_fee_mtokens': channel.node1_base_fee_mtokens,
'cltv_delta': channel.node1_cltv_delta,
'fee_rate': channel.node1_fee_rate,
'is_disabled': channel.node1_is_disabled,
'max_htlc_mtokens': channel.node1_max_htlc_mtokens,
'min_htlc_mtokens': channel.node1_min_htlc_mtokens,
'updated_at': channel.node1_updated_at,
},
'node_right': {
'alias': channel.alias_right,
'public_key': channel.node2_public_key,
'channels': channel.channels_right,
'capacity': channel.capacity_right,
'base_fee_mtokens': channel.node2_base_fee_mtokens,
'cltv_delta': channel.node2_cltv_delta,
'fee_rate': channel.node2_fee_rate,
'is_disabled': channel.node2_is_disabled,
'max_htlc_mtokens': channel.node2_max_htlc_mtokens,
'min_htlc_mtokens': channel.node2_min_htlc_mtokens,
'updated_at': channel.node2_updated_at,
},
};
}
}
export default new ChannelsApi();

View File

@ -0,0 +1,98 @@
import config from '../../config';
import { Application, Request, Response } from 'express';
import channelsApi from './channels.api';
class ChannelsRoutes {
constructor() { }
public initRoutes(app: Application) {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/txids', this.$getChannelsByTransactionIds)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
;
}
private async $searchChannelsById(req: Request, res: Response) {
try {
const channels = await channelsApi.$searchChannelsById(req.params.search);
res.json(channels);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getChannel(req: Request, res: Response) {
try {
const channel = await channelsApi.$getChannel(req.params.short_id);
if (!channel) {
res.status(404).send('Channel not found');
return;
}
res.json(channel);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getChannelsForNode(req: Request, res: Response) {
try {
if (typeof req.query.public_key !== 'string') {
res.status(400).send('Missing parameter: public_key');
return;
}
const index = parseInt(typeof req.query.index === 'string' ? req.query.index : '0', 10) || 0;
const status: string = typeof req.query.status === 'string' ? req.query.status : '';
const length = 25;
const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, length, status);
const channelsCount = await channelsApi.$getChannelsCountForNode(req.query.public_key, status);
res.header('X-Total-Count', channelsCount.toString());
res.json(channels);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getChannelsByTransactionIds(req: Request, res: Response) {
try {
if (!Array.isArray(req.query.txId)) {
res.status(400).send('Not an array');
return;
}
const txIds: string[] = [];
for (const _txId in req.query.txId) {
if (typeof req.query.txId[_txId] === 'string') {
txIds.push(req.query.txId[_txId].toString());
}
}
const channels = await channelsApi.$getChannelsByTransactionId(txIds);
const inputs: any[] = [];
const outputs: any[] = [];
for (const txid of txIds) {
const foundChannelInputs = channels.find((channel) => channel.closing_transaction_id === txid);
if (foundChannelInputs) {
inputs.push(foundChannelInputs);
} else {
inputs.push(null);
}
const foundChannelOutputs = channels.find((channel) => channel.transaction_id === txid);
if (foundChannelOutputs) {
outputs.push(foundChannelOutputs);
} else {
outputs.push(null);
}
}
res.json({
inputs: inputs,
outputs: outputs,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new ChannelsRoutes();

View File

@ -0,0 +1,53 @@
import config from '../../config';
import { Application, Request, Response } from 'express';
import nodesApi from './nodes.api';
import channelsApi from './channels.api';
import statisticsApi from './statistics.api';
class GeneralLightningRoutes {
constructor() { }
public initRoutes(app: Application) {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/search', this.$searchNodesAndChannels)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/latest', this.$getGeneralStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics', this.$getStatistics)
;
}
private async $searchNodesAndChannels(req: Request, res: Response) {
if (typeof req.query.searchText !== 'string') {
res.status(400).send('Missing parameter: searchText');
return;
}
try {
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.query.searchText);
const channels = await channelsApi.$searchChannelsById(req.query.searchText);
res.json({
nodes: nodes,
channels: channels,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getStatistics(req: Request, res: Response) {
try {
const statistics = await statisticsApi.$getStatistics();
res.json(statistics);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getGeneralStats(req: Request, res: Response) {
try {
const statistics = await statisticsApi.$getLatestStatistics();
res.json(statistics);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new GeneralLightningRoutes();

View File

@ -0,0 +1,62 @@
import logger from '../../logger';
import DB from '../../database';
class NodesApi {
public async $getNode(public_key: string): Promise<any> {
try {
const query = `SELECT nodes.*, (SELECT COUNT(*) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS channel_count, (SELECT SUM(capacity) FROM channels WHERE channels.status < 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)) AS capacity, (SELECT AVG(capacity) FROM channels WHERE status < 2 AND (node1_public_key = ? OR node2_public_key = ?)) AS channels_capacity_avg FROM nodes WHERE public_key = ?`;
const [rows]: any = await DB.query(query, [public_key, public_key, public_key, public_key, public_key, public_key, public_key]);
return rows[0];
} catch (e) {
logger.err('$getNode error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getNodeStats(public_key: string): Promise<any> {
try {
const query = `SELECT UNIX_TIMESTAMP(added) AS added, capacity, channels FROM node_stats WHERE public_key = ? ORDER BY added DESC`;
const [rows]: any = await DB.query(query, [public_key]);
return rows;
} catch (e) {
logger.err('$getNodeStats error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getTopCapacityNodes(): Promise<any> {
try {
const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.capacity DESC LIMIT 10`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getTopCapacityNodes error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getTopChannelsNodes(): Promise<any> {
try {
const query = `SELECT nodes.*, node_stats.capacity, node_stats.channels FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key ORDER BY node_stats.added DESC, node_stats.channels DESC LIMIT 10`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $searchNodeByPublicKeyOrAlias(search: string) {
try {
const searchStripped = search.replace('%', '') + '%';
const query = `SELECT nodes.public_key, nodes.alias, node_stats.capacity FROM nodes LEFT JOIN node_stats ON node_stats.public_key = nodes.public_key WHERE nodes.public_key LIKE ? OR nodes.alias LIKE ? GROUP BY nodes.public_key ORDER BY node_stats.capacity DESC LIMIT 10`;
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
return rows;
} catch (e) {
logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new NodesApi();

View File

@ -0,0 +1,61 @@
import config from '../../config';
import { Application, Request, Response } from 'express';
import nodesApi from './nodes.api';
class NodesRoutes {
constructor() { }
public initRoutes(app: Application) {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
;
}
private async $searchNode(req: Request, res: Response) {
try {
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search);
res.json(nodes);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getNode(req: Request, res: Response) {
try {
const node = await nodesApi.$getNode(req.params.public_key);
if (!node) {
res.status(404).send('Node not found');
return;
}
res.json(node);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getHistoricalNodeStats(req: Request, res: Response) {
try {
const statistics = await nodesApi.$getNodeStats(req.params.public_key);
res.json(statistics);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getTopNodes(req: Request, res: Response) {
try {
const topCapacityNodes = await nodesApi.$getTopCapacityNodes();
const topChannelsNodes = await nodesApi.$getTopChannelsNodes();
res.json({
topByCapacity: topCapacityNodes,
topByChannels: topChannelsNodes,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new NodesRoutes();

View File

@ -0,0 +1,32 @@
import logger from '../../logger';
import DB from '../../database';
class StatisticsApi {
public async $getStatistics(): Promise<any> {
try {
const query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity FROM lightning_stats ORDER BY id DESC`;
const [rows]: any = await DB.query(query);
return rows;
} catch (e) {
logger.err('$getStatistics error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $getLatestStatistics(): Promise<any> {
try {
const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1`);
const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1 OFFSET 72`);
return {
latest: rows[0],
previous: rows2[0],
};
} catch (e) {
logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new StatisticsApi();

View File

@ -0,0 +1,7 @@
import { ILightningApi } from './lightning-api.interface';
export interface AbstractLightningApi {
$getNetworkInfo(): Promise<ILightningApi.NetworkInfo>;
$getNetworkGraph(): Promise<ILightningApi.NetworkGraph>;
$getInfo(): Promise<ILightningApi.Info>;
}

View File

@ -0,0 +1,13 @@
import config from '../../config';
import { AbstractLightningApi } from './lightning-api-abstract-factory';
import LndApi from './lnd/lnd-api';
function lightningApiFactory(): AbstractLightningApi {
switch (config.LIGHTNING.BACKEND) {
case 'lnd':
default:
return new LndApi();
}
}
export default lightningApiFactory();

View File

@ -0,0 +1,71 @@
export namespace ILightningApi {
export interface NetworkInfo {
average_channel_size: number;
channel_count: number;
max_channel_size: number;
median_channel_size: number;
min_channel_size: number;
node_count: number;
not_recently_updated_policy_count: number;
total_capacity: number;
}
export interface NetworkGraph {
channels: Channel[];
nodes: Node[];
}
export interface Channel {
id: string;
capacity: number;
policies: Policy[];
transaction_id: string;
transaction_vout: number;
updated_at?: string;
}
interface Policy {
public_key: string;
base_fee_mtokens?: string;
cltv_delta?: number;
fee_rate?: number;
is_disabled?: boolean;
max_htlc_mtokens?: string;
min_htlc_mtokens?: string;
updated_at?: string;
}
export interface Node {
alias: string;
color: string;
features: Feature[];
public_key: string;
sockets: string[];
updated_at?: string;
}
export interface Info {
chains: string[];
color: string;
active_channels_count: number;
alias: string;
current_block_hash: string;
current_block_height: number;
features: Feature[];
is_synced_to_chain: boolean;
is_synced_to_graph: boolean;
latest_block_at: string;
peers_count: number;
pending_channels_count: number;
public_key: string;
uris: any[];
version: string;
}
export interface Feature {
bit: number;
is_known: boolean;
is_required: boolean;
type?: string;
}
}

View File

@ -0,0 +1,45 @@
import { AbstractLightningApi } from '../lightning-api-abstract-factory';
import { ILightningApi } from '../lightning-api.interface';
import * as fs from 'fs';
import { authenticatedLndGrpc, getWalletInfo, getNetworkGraph, getNetworkInfo } from 'lightning';
import config from '../../../config';
import logger from '../../../logger';
class LndApi implements AbstractLightningApi {
private lnd: any;
constructor() {
if (!config.LIGHTNING.ENABLED) {
return;
}
try {
const tls = fs.readFileSync(config.LND.TLS_CERT_PATH).toString('base64');
const macaroon = fs.readFileSync(config.LND.MACAROON_PATH).toString('base64');
const { lnd } = authenticatedLndGrpc({
cert: tls,
macaroon: macaroon,
socket: config.LND.SOCKET,
});
this.lnd = lnd;
} catch (e) {
logger.err('Could not initiate the LND service handler: ' + (e instanceof Error ? e.message : e));
process.exit(1);
}
}
async $getNetworkInfo(): Promise<ILightningApi.NetworkInfo> {
return await getNetworkInfo({ lnd: this.lnd });
}
async $getInfo(): Promise<ILightningApi.Info> {
// @ts-ignore
return await getWalletInfo({ lnd: this.lnd });
}
async $getNetworkGraph(): Promise<ILightningApi.NetworkGraph> {
return await getNetworkGraph({ lnd: this.lnd });
}
}
export default LndApi;

View File

@ -28,6 +28,15 @@ interface IConfig {
ESPLORA: {
REST_API_URL: string;
};
LIGHTNING: {
ENABLED: boolean;
BACKEND: 'lnd' | 'cln' | 'ldk';
};
LND: {
TLS_CERT_PATH: string;
MACAROON_PATH: string;
SOCKET: string;
};
ELECTRUM: {
HOST: string;
PORT: number;
@ -160,6 +169,15 @@ const defaults: IConfig = {
'ENABLED': false,
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
},
'LIGHTNING': {
'ENABLED': false,
'BACKEND': 'lnd'
},
'LND': {
'TLS_CERT_PATH': '',
'MACAROON_PATH': '',
'SOCKET': 'localhost:10009',
},
'SOCKS5PROXY': {
'ENABLED': false,
'USE_ONION': true,
@ -168,11 +186,11 @@ const defaults: IConfig = {
'USERNAME': '',
'PASSWORD': ''
},
"PRICE_DATA_SERVER": {
'PRICE_DATA_SERVER': {
'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
},
"EXTERNAL_DATA_SERVER": {
'EXTERNAL_DATA_SERVER': {
'MEMPOOL_API': 'https://mempool.space/api/v1',
'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
'LIQUID_API': 'https://liquid.network/api/v1',
@ -192,6 +210,8 @@ class Config implements IConfig {
SYSLOG: IConfig['SYSLOG'];
STATISTICS: IConfig['STATISTICS'];
BISQ: IConfig['BISQ'];
LIGHTNING: IConfig['LIGHTNING'];
LND: IConfig['LND'];
SOCKS5PROXY: IConfig['SOCKS5PROXY'];
PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
@ -207,6 +227,8 @@ class Config implements IConfig {
this.SYSLOG = configs.SYSLOG;
this.STATISTICS = configs.STATISTICS;
this.BISQ = configs.BISQ;
this.LIGHTNING = configs.LIGHTNING;
this.LND = configs.LND;
this.SOCKS5PROXY = configs.SOCKS5PROXY;
this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;

View File

@ -1,5 +1,5 @@
import express from "express";
import { Application, Request, Response, NextFunction, Express } from 'express';
import { Application, Request, Response, NextFunction } from 'express';
import * as http from 'http';
import * as WebSocket from 'ws';
import cluster from 'cluster';
@ -28,6 +28,11 @@ import { Common } from './api/common';
import poolsUpdater from './tasks/pools-updater';
import indexer from './indexer';
import priceUpdater from './tasks/price-updater';
import nodesRoutes from './api/explorer/nodes.routes';
import channelsRoutes from './api/explorer/channels.routes';
import generalLightningRoutes from './api/explorer/general.routes';
import lightningStatsUpdater from './tasks/lightning/stats-updater.service';
import nodeSyncService from './tasks/lightning/node-sync.service';
import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
class Server {
@ -130,6 +135,11 @@ class Server {
bisqMarkets.startBisqService();
}
if (config.LIGHTNING.ENABLED) {
nodeSyncService.$startService()
.then(() => lightningStatsUpdater.$startService());
}
this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
if (worker) {
logger.info(`Mempool Server worker #${process.pid} started`);
@ -362,6 +372,12 @@ class Server {
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
;
}
if (config.LIGHTNING.ENABLED) {
generalLightningRoutes.initRoutes(this.app);
nodesRoutes.initRoutes(this.app);
channelsRoutes.initRoutes(this.app);
}
}
}

View File

@ -0,0 +1,396 @@
import { chanNumber } from 'bolt07';
import DB from '../../database';
import logger from '../../logger';
import channelsApi from '../../api/explorer/channels.api';
import bitcoinClient from '../../api/bitcoin/bitcoin-client';
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
import config from '../../config';
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
import lightningApi from '../../api/lightning/lightning-api-factory';
import { ILightningApi } from '../../api/lightning/lightning-api.interface';
class NodeSyncService {
constructor() {}
public async $startService() {
logger.info('Starting node sync service');
await this.$runUpdater();
setInterval(async () => {
await this.$runUpdater();
}, 1000 * 60 * 60);
}
private async $runUpdater() {
try {
logger.info(`Updating nodes and channels...`);
const networkGraph = await lightningApi.$getNetworkGraph();
for (const node of networkGraph.nodes) {
await this.$saveNode(node);
}
logger.info(`Nodes updated.`);
await this.$setChannelsInactive();
for (const channel of networkGraph.channels) {
await this.$saveChannel(channel);
}
logger.info(`Channels updated.`);
await this.$findInactiveNodesAndChannels();
await this.$lookUpCreationDateFromChain();
await this.$updateNodeFirstSeen();
await this.$scanForClosedChannels();
await this.$runClosedChannelsForensics();
} catch (e) {
logger.err('$updateNodes() error: ' + (e instanceof Error ? e.message : e));
}
}
// This method look up the creation date of the earliest channel of the node
// and update the node to that date in order to get the earliest first seen date
private async $updateNodeFirstSeen() {
try {
const [nodes]: any[] = await DB.query(`SELECT nodes.public_key, UNIX_TIMESTAMP(nodes.first_seen) AS first_seen, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node1_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created1, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node2_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created2 FROM nodes`);
for (const node of nodes) {
let lowest = 0;
if (node.created1) {
if (node.created2 && node.created2 < node.created1) {
lowest = node.created2;
} else {
lowest = node.created1;
}
} else if (node.created2) {
lowest = node.created2;
}
if (lowest && lowest < node.first_seen) {
const query = `UPDATE nodes SET first_seen = FROM_UNIXTIME(?) WHERE public_key = ?`;
const params = [lowest, node.public_key];
await DB.query(query, params);
}
}
logger.info(`Node first seen dates scan complete.`);
} catch (e) {
logger.err('$updateNodeFirstSeen() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $lookUpCreationDateFromChain() {
logger.info(`Running channel creation date lookup...`);
try {
const channels = await channelsApi.$getChannelsWithoutCreatedDate();
for (const channel of channels) {
const transaction = await bitcoinClient.getRawTransaction(channel.transaction_id, 1);
await DB.query(`UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.id = ?`, [transaction.blocktime, channel.id]);
}
logger.info(`Channel creation dates scan complete.`);
} catch (e) {
logger.err('$setCreationDateFromChain() error: ' + (e instanceof Error ? e.message : e));
}
}
// Looking for channels whos nodes are inactive
private async $findInactiveNodesAndChannels(): Promise<void> {
logger.info(`Running inactive channels scan...`);
try {
// @ts-ignore
const [channels]: [ILightningApi.Channel[]] = await DB.query(`SELECT channels.id FROM channels WHERE channels.status = 1 AND ((SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node1_public_key) = 0 OR (SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node2_public_key) = 0)`);
for (const channel of channels) {
await this.$updateChannelStatus(channel.id, 0);
}
logger.info(`Inactive channels scan complete.`);
} catch (e) {
logger.err('$findInactiveNodesAndChannels() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $scanForClosedChannels(): Promise<void> {
try {
logger.info(`Starting closed channels scan...`);
const channels = await channelsApi.$getChannelsByStatus(0);
for (const channel of channels) {
const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
logger.debug('Marking channel: ' + channel.id + ' as closed.');
await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`,
[spendingTx.status.block_time, channel.id]);
if (spendingTx.txid && !channel.closing_transaction_id) {
await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]);
}
}
}
logger.info(`Closed channels scan complete.`);
} catch (e) {
logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e));
}
}
/*
1. Mutually closed
2. Forced closed
3. Forced closed with penalty
*/
private async $runClosedChannelsForensics(): Promise<void> {
if (!config.ESPLORA.REST_API_URL) {
return;
}
try {
logger.info(`Started running closed channel forensics...`);
const channels = await channelsApi.$getClosedChannelsWithoutReason();
for (const channel of channels) {
let reason = 0;
// Only Esplora backend can retrieve spent transaction outputs
const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
const lightningScriptReasons: number[] = [];
for (const outspend of outspends) {
if (outspend.spent && outspend.txid) {
const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
}
}
if (lightningScriptReasons.length === outspends.length
&& lightningScriptReasons.filter((r) => r === 1).length === outspends.length) {
reason = 1;
} else {
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
}
} else {
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
const closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
reason = 2; // Here we can't be sure if it's a penalty or not
} else {
reason = 1;
}
}
}
if (reason) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
}
}
logger.info(`Closed channels forensics scan complete.`);
} catch (e) {
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
}
}
private findLightningScript(vin: IEsploraApi.Vin): number {
const topElement = vin.witness[vin.witness.length - 2];
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
if (topElement === '01') {
// top element is '01' to get in the revocation path
// 'Revoked Lightning Force Close';
// Penalty force closed
return 2;
} else {
// top element is '', this is a delayed to_local output
// 'Lightning Force Close';
return 3;
}
} else if (
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
if (topElement.length === 66) {
// top element is a public key
// 'Revoked Lightning HTLC'; Penalty force closed
return 4;
} else if (topElement) {
// top element is a preimage
// 'Lightning HTLC';
return 5;
} else {
// top element is '' to get in the expiry of the script
// 'Expired Lightning HTLC';
return 6;
}
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if (topElement) {
// top element is a signature
// 'Lightning Anchor';
return 7;
} else {
// top element is '', it has been swept after 16 blocks
// 'Swept Lightning Anchor';
return 8;
}
}
return 1;
}
private async $saveChannel(channel: ILightningApi.Channel): Promise<void> {
const fromChannel = chanNumber({ channel: channel.id }).number;
try {
const query = `INSERT INTO channels
(
id,
short_id,
capacity,
transaction_id,
transaction_vout,
updated_at,
status,
node1_public_key,
node1_base_fee_mtokens,
node1_cltv_delta,
node1_fee_rate,
node1_is_disabled,
node1_max_htlc_mtokens,
node1_min_htlc_mtokens,
node1_updated_at,
node2_public_key,
node2_base_fee_mtokens,
node2_cltv_delta,
node2_fee_rate,
node2_is_disabled,
node2_max_htlc_mtokens,
node2_min_htlc_mtokens,
node2_updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
capacity = ?,
updated_at = ?,
status = 1,
node1_public_key = ?,
node1_base_fee_mtokens = ?,
node1_cltv_delta = ?,
node1_fee_rate = ?,
node1_is_disabled = ?,
node1_max_htlc_mtokens = ?,
node1_min_htlc_mtokens = ?,
node1_updated_at = ?,
node2_public_key = ?,
node2_base_fee_mtokens = ?,
node2_cltv_delta = ?,
node2_fee_rate = ?,
node2_is_disabled = ?,
node2_max_htlc_mtokens = ?,
node2_min_htlc_mtokens = ?,
node2_updated_at = ?
;`;
await DB.query(query, [
fromChannel,
channel.id,
channel.capacity,
channel.transaction_id,
channel.transaction_vout,
channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0,
channel.policies[0].public_key,
channel.policies[0].base_fee_mtokens,
channel.policies[0].cltv_delta,
channel.policies[0].fee_rate,
channel.policies[0].is_disabled,
channel.policies[0].max_htlc_mtokens,
channel.policies[0].min_htlc_mtokens,
channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0,
channel.policies[1].public_key,
channel.policies[1].base_fee_mtokens,
channel.policies[1].cltv_delta,
channel.policies[1].fee_rate,
channel.policies[1].is_disabled,
channel.policies[1].max_htlc_mtokens,
channel.policies[1].min_htlc_mtokens,
channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0,
channel.capacity,
channel.updated_at ? this.utcDateToMysql(channel.updated_at) : 0,
channel.policies[0].public_key,
channel.policies[0].base_fee_mtokens,
channel.policies[0].cltv_delta,
channel.policies[0].fee_rate,
channel.policies[0].is_disabled,
channel.policies[0].max_htlc_mtokens,
channel.policies[0].min_htlc_mtokens,
channel.policies[0].updated_at ? this.utcDateToMysql(channel.policies[0].updated_at) : 0,
channel.policies[1].public_key,
channel.policies[1].base_fee_mtokens,
channel.policies[1].cltv_delta,
channel.policies[1].fee_rate,
channel.policies[1].is_disabled,
channel.policies[1].max_htlc_mtokens,
channel.policies[1].min_htlc_mtokens,
channel.policies[1].updated_at ? this.utcDateToMysql(channel.policies[1].updated_at) : 0,
]);
} catch (e) {
logger.err('$saveChannel() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $updateChannelStatus(channelShortId: string, status: number): Promise<void> {
try {
await DB.query(`UPDATE channels SET status = ? WHERE id = ?`, [status, channelShortId]);
} catch (e) {
logger.err('$updateChannelStatus() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $setChannelsInactive(): Promise<void> {
try {
await DB.query(`UPDATE channels SET status = 0 WHERE status = 1`);
} catch (e) {
logger.err('$setChannelsInactive() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $saveNode(node: ILightningApi.Node): Promise<void> {
try {
const updatedAt = node.updated_at ? this.utcDateToMysql(node.updated_at) : '0000-00-00 00:00:00';
const sockets = node.sockets.join(',');
const query = `INSERT INTO nodes(
public_key,
first_seen,
updated_at,
alias,
color,
sockets
)
VALUES (?, NOW(), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at = ?, alias = ?, color = ?, sockets = ?;`;
await DB.query(query, [
node.public_key,
updatedAt,
node.alias,
node.color,
sockets,
updatedAt,
node.alias,
node.color,
sockets,
]);
} catch (e) {
logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e));
}
}
private utcDateToMysql(dateString: string): string {
const d = new Date(Date.parse(dateString));
return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0];
}
}
export default new NodeSyncService();

View File

@ -0,0 +1,201 @@
import logger from "../../logger";
import DB from "../../database";
import lightningApi from "../../api/lightning/lightning-api-factory";
class LightningStatsUpdater {
constructor() {}
public async $startService() {
logger.info('Starting Lightning Stats service');
let isInSync = false;
let error: any;
try {
error = null;
isInSync = await this.$lightningIsSynced();
} catch (e) {
error = e;
}
if (!isInSync) {
if (error) {
logger.warn('Was not able to fetch Lightning Node status: ' + (error instanceof Error ? error.message : error) + '. Retrying in 1 minute...');
} else {
logger.notice('The Lightning graph is not yet in sync. Retrying in 1 minute...');
}
setTimeout(() => this.$startService(), 60 * 1000);
return;
}
const now = new Date();
const nextHourInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Math.floor(now.getHours() / 1) + 1, 0, 0, 0);
const difference = nextHourInterval.getTime() - now.getTime();
setTimeout(() => {
setInterval(async () => {
await this.$runTasks();
}, 1000 * 60 * 60);
}, difference);
await this.$runTasks();
}
private async $lightningIsSynced(): Promise<boolean> {
const nodeInfo = await lightningApi.$getInfo();
return nodeInfo.is_synced_to_chain && nodeInfo.is_synced_to_graph;
}
private async $runTasks() {
await this.$populateHistoricalData();
await this.$logLightningStatsDaily();
await this.$logNodeStatsDaily();
}
private async $logNodeStatsDaily() {
const currentDate = new Date().toISOString().split('T')[0];
try {
const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`);
// Only store once per day
if (state[0].string === currentDate) {
return;
}
logger.info(`Running daily node stats update...`);
const query = `SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, c2.channels_capacity_right FROM nodes LEFT JOIN (SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left FROM channels WHERE channels.status < 2 GROUP BY node1_public_key) c1 ON c1.node1_public_key = nodes.public_key LEFT JOIN (SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right FROM channels WHERE channels.status < 2 GROUP BY node2_public_key) c2 ON c2.node2_public_key = nodes.public_key`;
const [nodes]: any = await DB.query(query);
// First run we won't have any nodes yet
if (nodes.length < 10) {
return;
}
for (const node of nodes) {
await DB.query(
`INSERT INTO node_stats(public_key, added, capacity, channels) VALUES (?, NOW(), ?, ?)`,
[node.public_key, (parseInt(node.channels_capacity_left || 0, 10)) + (parseInt(node.channels_capacity_right || 0, 10)),
node.channels_count_left + node.channels_count_right]);
}
await DB.query(`UPDATE state SET string = ? WHERE name = 'last_node_stats'`, [currentDate]);
logger.info('Daily node stats has updated.');
} catch (e) {
logger.err('$logNodeStatsDaily() error: ' + (e instanceof Error ? e.message : e));
}
}
// We only run this on first launch
private async $populateHistoricalData() {
const startTime = '2018-01-13';
try {
const [rows]: any = await DB.query(`SELECT COUNT(*) FROM lightning_stats`);
// Only store once per day
if (rows[0]['COUNT(*)'] > 0) {
return;
}
logger.info(`Running historical stats population...`);
const [channels]: any = await DB.query(`SELECT capacity, created, closing_date FROM channels ORDER BY created ASC`);
let date: Date = new Date(startTime);
const currentDate = new Date();
while (date < currentDate) {
let totalCapacity = 0;
let channelsCount = 0;
for (const channel of channels) {
if (new Date(channel.created) > date) {
break;
}
if (channel.closing_date !== null && new Date(channel.closing_date) < date) {
continue;
}
totalCapacity += channel.capacity;
channelsCount++;
}
const query = `INSERT INTO lightning_stats(
added,
channel_count,
node_count,
total_capacity
)
VALUES (FROM_UNIXTIME(?), ?, ?, ?)`;
await DB.query(query, [
date.getTime() / 1000,
channelsCount,
0,
totalCapacity,
]);
// Add one day and continue
date.setDate(date.getDate() + 1);
}
const [nodes]: any = await DB.query(`SELECT first_seen FROM nodes ORDER BY first_seen ASC`);
date = new Date(startTime);
while (date < currentDate) {
let nodeCount = 0;
for (const node of nodes) {
if (new Date(node.first_seen) > date) {
break;
}
nodeCount++;
}
const query = `UPDATE lightning_stats SET node_count = ? WHERE added = FROM_UNIXTIME(?)`;
await DB.query(query, [
nodeCount,
date.getTime() / 1000,
]);
// Add one day and continue
date.setDate(date.getDate() + 1);
}
logger.info('Historical stats populated.');
} catch (e) {
logger.err('$populateHistoricalData() error: ' + (e instanceof Error ? e.message : e));
}
}
private async $logLightningStatsDaily() {
const currentDate = new Date().toISOString().split('T')[0];
try {
const [state]: any = await DB.query(`SELECT string FROM state WHERE name = 'last_node_stats'`);
// Only store once per day
if (state[0].string === currentDate) {
return;
}
logger.info(`Running lightning daily stats log...`);
const networkGraph = await lightningApi.$getNetworkGraph();
let total_capacity = 0;
for (const channel of networkGraph.channels) {
if (channel.capacity) {
total_capacity += channel.capacity;
}
}
const query = `INSERT INTO lightning_stats(
added,
channel_count,
node_count,
total_capacity
)
VALUES (NOW(), ?, ?, ?)`;
await DB.query(query, [
networkGraph.channels.length,
networkGraph.nodes.length,
total_capacity,
]);
logger.info(`Lightning daily stats done.`);
} catch (e) {
logger.err('$logLightningStatsDaily() error: ' + (e instanceof Error ? e.message : e));
}
}
}
export default new LightningStatsUpdater();

View File

@ -121,20 +121,20 @@ describe('Mainnet', () => {
cy.visit('/');
cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
cy.wait('@search-1wiz');
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 10);
cy.get('app-search-results button.dropdown-item').should('have.length', 10);
});
cy.get('.search-box-container > .form-control').type('S').then(() => {
cy.wait('@search-1wizS');
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 5);
cy.get('app-search-results button.dropdown-item').should('have.length', 5);
});
cy.get('.search-box-container > .form-control').type('A').then(() => {
cy.wait('@search-1wizSA');
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1)
cy.get('app-search-results button.dropdown-item').should('have.length', 1)
});
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC');
cy.waitForSkeletonGone();
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
@ -145,8 +145,8 @@ describe('Mainnet', () => {
it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => {
cy.visit('/');
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
cy.get('app-search-results button.dropdown-item').should('have.length', 1);
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
cy.waitForSkeletonGone();
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
@ -159,8 +159,8 @@ describe('Mainnet', () => {
it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => {
cy.visit('/');
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
cy.get('app-search-results button.dropdown-item').should('have.length', 1);
cy.get('app-search-results button.dropdown-item.active').click().then(() => {
cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
cy.waitForSkeletonGone();
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');

View File

@ -16,5 +16,6 @@
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
"LIQUID_WEBSITE_URL": "https://liquid.network",
"BISQ_WEBSITE_URL": "https://bisq.markets",
"MINING_DASHBOARD": true
"MINING_DASHBOARD": true,
"LIGHTNING": false
}

View File

@ -1,12 +1,12 @@
{
"name": "mempool-frontend",
"version": "2.4.1-dev",
"version": "2.5.0-dev",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mempool-frontend",
"version": "2.4.1-dev",
"version": "2.5.0-dev",
"license": "GNU Affero General Public License v3.0",
"dependencies": {
"@angular-devkit/build-angular": "~13.3.7",

View File

@ -1,6 +1,6 @@
{
"name": "mempool-frontend",
"version": "2.4.1-dev",
"version": "2.5.0-dev",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",

View File

@ -102,6 +102,16 @@ if (configContent && configContent.BASE_MODULE === 'bisq') {
}
PROXY_CONFIG.push(...[
{
context: ['/testnet/api/v1/lightning/**'],
target: `http://localhost:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/testnet": ""
},
},
{
context: ['/api/v1/**'],
target: `http://localhost:8999`,

View File

@ -96,6 +96,10 @@ let routes: Routes = [
path: 'api',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
},
{
path: 'lightning',
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
},
],
},
{
@ -186,6 +190,10 @@ let routes: Routes = [
path: 'api',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
},
{
path: 'lightning',
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
},
],
},
{
@ -273,6 +281,10 @@ let routes: Routes = [
path: 'api',
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
},
{
path: 'lightning',
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
},
],
},
{

View File

@ -1,4 +1,13 @@
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
<a *ngIf="channel; else default" [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
</a>
<ng-template #default>
<span
*ngIf="label"
class="badge badge-pill badge-warning"
>{{ label }}</span>
</ng-template>

View File

@ -1,4 +1,4 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
import { Vin, Vout } from '../../interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
@ -8,11 +8,12 @@ import { StateService } from 'src/app/services/state.service';
styleUrls: ['./address-labels.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressLabelsComponent implements OnInit {
export class AddressLabelsComponent implements OnChanges {
network = '';
@Input() vin: Vin;
@Input() vout: Vout;
@Input() channel: any;
label?: string;
@ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit {
this.network = stateService.network;
}
ngOnInit() {
if (this.vin) {
ngOnChanges() {
if (this.channel) {
this.handleChannel();
} else if (this.vin) {
this.handleVin();
} else if (this.vout) {
this.handleVout();
}
}
handleChannel() {
const type = this.vout ? 'open' : 'close';
this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`;
}
handleVin() {
if (this.vin.inner_witnessscript_asm) {
if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) {

View File

@ -55,10 +55,7 @@
<tr>
<td i18n="block.timestamp">Timestamp</td>
<td>
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i class="symbol">(<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>)</i>
</div>
<app-timestamp [unixTime]="block.timestamp"></app-timestamp>
</td>
</tr>
<tr>

View File

@ -0,0 +1,3 @@
<span [style]="change >= 0 ? 'color: #42B747' : 'color: #B74242'">
{{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}%
</span>

View File

@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-change',
templateUrl: './change.component.html',
styleUrls: ['./change.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChangeComponent implements OnChanges {
@Input() current: number;
@Input() previous: number;
change: number;
constructor() { }
ngOnChanges(): void {
this.change = (this.current - this.previous) / this.previous * 100;
}
}

View File

@ -1,5 +1,5 @@
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0" style="line-height: 0.9;" [attr.data-clipboard-text]="text">
<img src="./resources/clippy.svg" width="13">
<button #btn class="btn btn-sm btn-link pt-0" [style]="{'line-height': size === 'small' ? '0.2' : '0.8'}" [attr.data-clipboard-text]="text">
<img src="./resources/clippy.svg" [width]="size === 'small' ? 10 : 13">
</button>
</span>

View File

@ -1,3 +1,8 @@
.btn-link {
padding: 0.25rem 0 0.1rem 0.5rem;
}
img {
position: relative;
left: -3px;
}

View File

@ -11,6 +11,7 @@ import * as tlite from 'tlite';
export class ClipboardComponent implements AfterViewInit {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
@Input() size: 'small' | 'normal' = 'normal';
@Input() text: string;
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;

View File

@ -35,6 +35,9 @@
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.LIGHTNING">
<a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a>
</li>

View File

@ -1,11 +1,12 @@
import { Component, Input, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { Component, Input, AfterViewInit, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import * as QRCode from 'qrcode';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-qrcode',
templateUrl: './qrcode.component.html',
styleUrls: ['./qrcode.component.scss']
styleUrls: ['./qrcode.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QrcodeComponent implements AfterViewInit {
@Input() data: string;
@ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit {
private stateService: StateService,
) { }
ngOnChanges() {
if (!this.canvas || !this.canvas.nativeElement) {
return;
}
this.render();
}
ngAfterViewInit() {
this.render();
}
render() {
if (!this.stateService.isBrowser) {
return;
}

View File

@ -1,7 +1,10 @@
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="d-flex">
<div class="search-box-container mr-2">
<input #instance="ngbTypeahead" [ngbTypeahead]="typeaheadSearchFn" [resultFormatter]="formatterFn" (selectItem)="itemSelected()" (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address">
<input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address">
<app-search-results #searchResults [results]="typeAhead$ | async" [searchTerm]="searchForm.get('searchText').value" (selectedResult)="selectedResult($event)"></app-search-results>
</div>
<div>
<button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"><fa-icon [icon]="['fas', 'search']" [fixedWidth]="true" i18n-title="search-form.search-title" title="Search"></fa-icon></button>

View File

@ -32,6 +32,7 @@ form {
}
.search-box-container {
position: relative;
width: 100%;
@media (min-width: 768px) {
min-width: 400px;
@ -48,4 +49,4 @@ form {
.btn {
width: 100px;
}
}
}

View File

@ -1,41 +1,40 @@
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild } from '@angular/core';
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AssetsService } from 'src/app/services/assets.service';
import { StateService } from 'src/app/services/state.service';
import { Observable, of, Subject, merge } from 'rxjs';
import { Observable, of, Subject, merge, zip } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators';
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
import { ShortenStringPipe } from 'src/app/shared/pipes/shorten-string-pipe/shorten-string.pipe';
import { ApiService } from 'src/app/services/api.service';
import { SearchResultsComponent } from './search-results/search-results.component';
@Component({
selector: 'app-search-form',
templateUrl: './search-form.component.html',
styleUrls: ['./search-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchFormComponent implements OnInit {
network = '';
assets: object = {};
isSearching = false;
typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
typeAhead$: Observable<any>;
searchForm: FormGroup;
isMobile = (window.innerWidth <= 767.98);
@Output() searchTriggered = new EventEmitter();
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
regexBlockheight = /^[0-9]+$/;
@ViewChild('instance', {static: true}) instance: NgbTypeahead;
focus$ = new Subject<string>();
click$ = new Subject<string>();
formatterFn = (address: string) => this.shortenStringPipe.transform(address, this.isMobile ? 33 : 40);
@Output() searchTriggered = new EventEmitter();
@ViewChild('searchResults') searchResults: SearchResultsComponent;
@HostListener('keydown', ['$event']) keydown($event) {
this.handleKeyDown($event);
}
constructor(
private formBuilder: FormBuilder,
@ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit {
private assetsService: AssetsService,
private stateService: StateService,
private electrsApiService: ElectrsApiService,
private apiService: ApiService,
private relativeUrlPipe: RelativeUrlPipe,
private shortenStringPipe: ShortenStringPipe,
) { }
ngOnInit() {
this.typeaheadSearchFn = this.typeaheadSearch;
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.searchForm = this.formBuilder.group({
@ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit {
this.assets = assets;
});
}
}
typeaheadSearch = (text$: Observable<string>) => {
const debouncedText$ = text$.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text;
}),
debounceTime(200),
distinctUntilChanged()
);
const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
const inputFocus$ = this.focus$;
return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$)
this.typeAhead$ = this.searchForm.get('searchText').valueChanges
.pipe(
map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1);
}
return text.trim();
}),
debounceTime(250),
distinctUntilChanged(),
switchMap((text) => {
if (!text.length) {
return of([]);
return of([
[],
{
nodes: [],
channels: [],
}
]);
}
return this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([])));
if (!this.stateService.env.LIGHTNING) {
return zip(
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
[{ nodes: [], channels: [] }]
);
}
return zip(
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
this.apiService.lightningSearch$(text).pipe(catchError(() => of({
nodes: [],
channels: [],
}))),
);
}),
map((result: string[]) => {
map((result: any[]) => {
if (this.network === 'bisq') {
return result.map((address: string) => 'B' + address);
return result[0].map((address: string) => 'B' + address);
}
return result;
return {
addresses: result[0],
nodes: result[1].nodes,
channels: result[1].channels,
totalResults: result[0].length + result[1].nodes.length + result[1].channels.length,
};
})
);
}
}
handleKeyDown($event) {
this.searchResults.handleKeyDown($event);
}
itemSelected() {
setTimeout(() => this.search());
}
search() {
const searchText = this.searchForm.value.searchText.trim();
selectedResult(result: any) {
if (typeof result === 'string') {
this.search(result);
} else if (result.alias) {
this.navigate('/lightning/node/', result.public_key);
} else if (result.short_id) {
this.navigate('/lightning/channel/', result.id);
}
}
search(result?: string) {
const searchText = result || this.searchForm.value.searchText.trim();
if (searchText) {
this.isSearching = true;
if (this.regexAddress.test(searchText)) {

View File

@ -0,0 +1,26 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.addresses.length && !results.nodes.length && !results.channels.length">
<ng-template [ngIf]="results.addresses.length">
<div class="card-title" *ngIf="stateService.env.LIGHTNING">Bitcoin Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(i)" [class.active]="i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="searchTerm"></ngb-highlight>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.nodes.length">
<div class="card-title">Lightning Nodes</div>
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
<button (click)="clickItem(results.addresses.length + i)" [class.active]="results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="node.alias" [term]="searchTerm"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.channels.length">
<div class="card-title">Lightning Channels</div>
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
<button (click)="clickItem(results.addresses.length + results.nodes.length + i)" [class.active]="results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="channel.short_id" [term]="searchTerm"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button>
</ng-template>
</ng-template>
</div>

View File

@ -0,0 +1,16 @@
.card-title {
color: #4a68b9;
font-size: 10px;
margin-bottom: 4px;
font-size: 1rem;
margin-left: 10px;
}
.dropdown-menu {
position: absolute;
top: 42px;
left: 0px;
box-shadow: 0.125rem 0.125rem 0.25rem rgba(0,0,0,0.075);
width: 100%;
}

View File

@ -0,0 +1,73 @@
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-search-results',
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
})
export class SearchResultsComponent implements OnChanges {
@Input() results: any = {};
@Input() searchTerm = '';
@Output() selectedResult = new EventEmitter();
isMobile = (window.innerWidth <= 767.98);
resultsFlattened = [];
activeIdx = 0;
focusFirst = true;
constructor(public stateService: StateService) { }
ngOnChanges() {
this.activeIdx = 0;
if (this.results) {
this.resultsFlattened = [...this.results.addresses, ...this.results.nodes, ...this.results.channels];
}
}
handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.next();
break;
case 'ArrowUp':
event.preventDefault();
this.prev();
break;
case 'Enter':
event.preventDefault();
if (this.resultsFlattened[this.activeIdx]) {
this.selectedResult.emit(this.resultsFlattened[this.activeIdx]);
} else {
this.selectedResult.emit(this.searchTerm);
}
this.results = null;
break;
}
}
clickItem(id: number) {
this.selectedResult.emit(this.resultsFlattened[id]);
this.results = null;
}
next() {
if (this.activeIdx === this.resultsFlattened.length - 1) {
this.activeIdx = this.focusFirst ? (this.activeIdx + 1) % this.resultsFlattened.length : -1;
} else {
this.activeIdx++;
}
}
prev() {
if (this.activeIdx < 0) {
this.activeIdx = this.resultsFlattened.length - 1;
} else if (this.activeIdx === 0) {
this.activeIdx = this.focusFirst ? this.resultsFlattened.length - 1 : -1;
} else {
this.activeIdx--;
}
}
}

View File

@ -13,6 +13,7 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
intervals = {};
@Input() time: number;
@Input() dateString: number;
@Input() fastRender = false;
constructor(
@ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
}
calculate() {
const seconds = Math.floor((+new Date() - +new Date(this.time * 1000)) / 1000);
let date: Date;
if (this.dateString) {
date = new Date(this.dateString)
} else {
date = new Date(this.time * 1000);
}
const seconds = Math.floor((+new Date() - +date) / 1000);
if (seconds < 60) {
return $localize`:@@date-base.just-now:Just now`;
}

View File

@ -77,7 +77,7 @@
{{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
</ng-template>
<div>
<app-address-labels [vin]="vin"></app-address-labels>
<app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels>
</div>
</ng-template>
</ng-container>
@ -172,7 +172,7 @@
</span>
</a>
<div>
<app-address-labels [vout]="vout"></app-address-labels>
<app-address-labels [vout]="vout" [channel]="channels && channels.outputs[i] && channels.outputs[i].transaction_vout === vindex ? channels.outputs[i] : null"></app-address-labels>
</div>
<ng-template #scriptpubkey_type>
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">

View File

@ -1,11 +1,11 @@
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs';
import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs';
import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from 'src/environments/environment';
import { AssetsService } from 'src/app/services/assets.service';
import { map, tap, switchMap } from 'rxjs/operators';
import { filter, map, tap, switchMap } from 'rxjs/operators';
import { BlockExtended } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service';
@ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
latestBlock$: Observable<BlockExtended>;
outspendsSubscription: Subscription;
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
showDetails$ = new BehaviorSubject<boolean>(false);
outspends: Outspend[][] = [];
assetsMinimal: any;
channels: { inputs: any[], outputs: any[] };
constructor(
public stateService: StateService,
@ -73,7 +75,16 @@ export class TransactionsListComponent implements OnInit, OnChanges {
};
}
}),
)
),
this.refreshChannels$
.pipe(
filter(() => this.stateService.env.LIGHTNING),
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
map((channels) => {
this.channels = channels;
}),
)
,
).subscribe(() => this.ref.markForCheck());
}
@ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
tx['addressValue'] = addressIn - addressOut;
}
});
this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid));
const txIds = this.transactions.map((tx) => tx.txid);
this.refreshOutspends$.next(txIds);
this.refreshChannels$.next(txIds);
}
onScroll() {

View File

@ -57,6 +57,9 @@ import { CommonModule } from '@angular/common';
NgxEchartsModule.forRoot({
echarts: () => import('echarts')
})
],
exports: [
NgxEchartsModule,
]
})
export class GraphsModule { }

View File

@ -0,0 +1,54 @@
<div class="mb-2 box-top">
<div class="box-left">
<h3 class="mb-0">{{ channel.alias || '?' }}</h3>
<a [routerLink]="['/lightning/node' | relativeUrl, channel.public_key]" >
{{ channel.public_key | shortenString : 12 }}
</a>
<app-clipboard [text]="channel.node1_public_key"></app-clipboard>
</div>
<div class="box-right">
<div class="second-line">{{ channel.channels }} channels</div>
<div class="second-line"><app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount></div>
</div>
</div>
<div class="box">
<div class="col-md">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="address.total-sent">Fee rate</td>
<td>
{{ channel.fee_rate }} <span class="symbol">ppm ({{ channel.fee_rate / 10000 | number }}%)</span>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Base fee</td>
<td>
<app-sats [satoshis]="channel.base_fee_mtokens / 1000" digitsInfo="1.0-2"></app-sats>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Min HTLC</td>
<td>
<app-sats [satoshis]="channel.min_htlc_mtokens / 1000"></app-sats>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Max HTLC</td>
<td>
<app-sats [satoshis]="channel.max_htlc_mtokens / 1000"></app-sats>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Timelock delta</td>
<td>
<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: channel.cltv_delta }"></ng-container>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>

View File

@ -0,0 +1,18 @@
.box-top {
display: flex;
}
.box-left {
width: 100%;
}
.box-right {
text-align: right;
width: 50%;
margin-top: auto;
}
.shared-block {
color: #ffffff66;
font-size: 12px;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChannelBoxComponent } from './channel-box.component';
describe('ChannelBoxComponent', () => {
let component: ChannelBoxComponent;
let fixture: ComponentFixture<ChannelBoxComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ChannelBoxComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ChannelBoxComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-channel-box',
templateUrl: './channel-box.component.html',
styleUrls: ['./channel-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelBoxComponent {
@Input() channel: any;
constructor() { }
}

View File

@ -0,0 +1,96 @@
<div class="container-xl" *ngIf="(channel$ | async) as channel">
<div class="title-container">
<h1 class="mb-0">{{ channel.short_id }}</h1>
<span class="tx-link">
<a [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">{{ channel.id }}</a>
<app-clipboard [text]="channel.id"></app-clipboard>
</span>
</div>
<div class="badges mb-2">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span>
<span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2">Closed</span>
</div>
<div class="clearfix"></div>
<div class="box">
<div class="row">
<div class="col-md">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="address.total-sent">Created</td>
<td><app-timestamp [dateString]="channel.created"></app-timestamp></td>
</tr>
<tr>
<td i18n="address.total-sent">Last update</td>
<td><app-timestamp [dateString]="channel.updated_at"></app-timestamp></td>
</tr>
<tr>
<td i18n="address.total-sent">Opening transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.transaction_id + ':' + channel.transaction_vout]" >
<span>{{ channel.transaction_id | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="channel.transaction_id"></app-clipboard>
</td>
</tr>
<ng-template [ngIf]="channel.closing_transaction_id">
<tr *ngIf="channel.closing_transaction_id">
<td i18n="address.total-sent">Closing transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.closing_transaction_id]" >
<span>{{ channel.closing_transaction_id | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="channel.closing_transaction_id"></app-clipboard>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Closing type</td>
<td>
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</td>
</tr>
</ng-template>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col-md">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="address.total-received">Capacity</td>
<td><app-sats [satoshis]="channel.capacity"></app-sats><app-fiat [value]="channel.capacity" digitsInfo="1.0-0"></app-fiat></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<app-channel-box [channel]="channel.node_left"></app-channel-box>
</div>
<div class="col">
<app-channel-box [channel]="channel.node_right"></app-channel-box>
</div>
</div>
</div>
<br>
<ng-template [ngIf]="error">
<div class="text-center">
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error.status }}: {{ error.error }}</i>
</div>
</ng-template>

View File

@ -0,0 +1,41 @@
.title-container {
display: flex;
flex-direction: row;
@media (max-width: 768px) {
flex-direction: column;
}
}
.tx-link {
display: flex;
flex-grow: 1;
@media (min-width: 650px) {
align-self: end;
margin-left: 15px;
margin-top: 0px;
margin-bottom: -3px;
}
@media (min-width: 768px) {
margin-bottom: 4px;
top: 1px;
position: relative;
}
@media (max-width: 768px) {
order: 2;
}
}
.badges {
font-size: 20px;
}
app-fiat {
display: block;
font-size: 13px;
@media (min-width: 768px) {
font-size: 14px;
display: inline-block;
margin-left: 10px;
}
}

View File

@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { LightningApiService } from '../lightning-api.service';
@Component({
selector: 'app-channel',
templateUrl: './channel.component.html',
styleUrls: ['./channel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelComponent implements OnInit {
channel$: Observable<any>;
error: any = null;
constructor(
private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.channel$ = this.activatedRoute.paramMap
.pipe(
switchMap((params: ParamMap) => {
this.error = null;
this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
return this.lightningApiService.getChannel$(params.get('short_id'))
.pipe(
catchError((err) => {
this.error = err;
console.log(this.error);
return of(null);
})
);
})
);
}
}

View File

@ -0,0 +1 @@
<span class="badge badge-pill badge-{{ label.class }}" >{{ label.label }}</span>

View File

@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
@Component({
selector: 'app-closing-type',
templateUrl: './closing-type.component.html',
styleUrls: ['./closing-type.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClosingTypeComponent implements OnChanges {
@Input() type = 0;
label: { label: string; class: string };
ngOnChanges() {
this.label = this.getLabelFromType(this.type);
}
getLabelFromType(type: number): { label: string; class: string } {
switch (type) {
case 1: return {
label: 'Mutually closed',
class: 'success',
};
case 2: return {
label: 'Force closed',
class: 'warning',
};
case 3: return {
label: 'Force closed with penalty',
class: 'danger',
};
default: return {
label: 'Unknown',
class: 'secondary',
};
}
}
}

View File

@ -0,0 +1,101 @@
<div *ngIf="channels$ | async as response; else skeleton">
<h2 class="float-left">Channels ({{ response.totalItems }})</h2>
<form [formGroup]="channelStatusForm" class="formRadioGroup float-right">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="status">
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'open'" fragment="open"> Open
</label>
<label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'closed'" fragment="closed"> Closed
</label>
</div>
</form>
<table class="table table-borderless">
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
<tbody>
<tr *ngFor="let channel of response.channels; let i = index;">
<ng-container *ngTemplateOutlet="tableTemplate; context: { $implicit: channel, node: channel.node_left.public_key === publicKey ? channel.node_right : channel.node_left }"></ng-container>
</tr>
</tbody>
</table>
<ngb-pagination class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
</div>
<ng-template #tableHeader>
<thead>
<th class="alias text-left" i18n="nodes.alias">Node Alias</th>
<th class="alias text-left d-none d-md-table-cell" i18n="channels.transaction">&nbsp;</th>
<th class="alias text-left d-none d-md-table-cell" i18n="nodes.alias">Status</th>
<th class="channels text-left d-none d-md-table-cell" i18n="channels.rate">Fee Rate</th>
<th class="capacity text-right d-none d-md-table-cell" i18n="nodes.capacity">Capacity</th>
<th class="capacity text-right" i18n="channels.id">Channel ID</th>
</thead>
</ng-template>
<ng-template #tableTemplate let-channel let-node="node">
<td class="alias text-left">
<div>{{ node.alias || '?' }}</div>
<div class="second-line">
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">
<span>{{ node.public_key | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="node.public_key" size="small"></app-clipboard>
</div>
</td>
<td class="alias text-left d-none d-md-table-cell">
<div class="second-line">{{ node.channels }} channels</div>
<div class="second-line"><app-amount [satoshis]="node.capacity" digitsInfo="1.2-2"></app-amount></div>
</td>
<td class="d-none d-md-table-cell">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span>
<ng-template [ngIf]="channel.status === 2">
<span class="badge rounded-pill badge-secondary" *ngIf="!channel.closing_reason; else closingReason">Closed</span>
<ng-template #closingReason>
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</ng-template>
</ng-template>
</td>
<td class="capacity text-left d-none d-md-table-cell">
{{ node.fee_rate }} <span class="symbol">ppm ({{ node.fee_rate / 10000 | number }}%)</span>
</td>
<td class="capacity text-right d-none d-md-table-cell">
<app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount>
</td>
<td class="capacity text-right">
<a [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">{{ channel.short_id }}</a>
</td>
</ng-template>
<ng-template #skeleton>
<h2 class="float-left">Channels</h2>
<table class="table table-borderless">
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
<tbody>
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td class="alias text-left" style="width: 370px;">
<span class="skeleton-loader"></span>
</td>
<td class="alias text-left">
<span class="skeleton-loader"></span>
</td>
<td class="capacity text-left d-none d-md-table-cell">
<span class="skeleton-loader"></span>
</td>
<td class="channels text-left d-none d-md-table-cell">
<span class="skeleton-loader"></span>
</td>
<td class="channels text-right d-none d-md-table-cell">
<span class="skeleton-loader"></span>
</td>
<td class="channels text-left">
<span class="skeleton-loader"></span>
</td>
</tr>
</tbody>
</table>
</ng-template>

View File

@ -0,0 +1,3 @@
.second-line {
font-size: 12px;
}

View File

@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { BehaviorSubject, combineLatest, merge, Observable, of } from 'rxjs';
import { map, startWith, switchMap } from 'rxjs/operators';
import { LightningApiService } from '../lightning-api.service';
@Component({
selector: 'app-channels-list',
templateUrl: './channels-list.component.html',
styleUrls: ['./channels-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelsListComponent implements OnInit, OnChanges {
@Input() publicKey: string;
channels$: Observable<any>;
// @ts-ignore
paginationSize: 'sm' | 'lg' = 'md';
paginationMaxSize = 10;
itemsPerPage = 25;
page = 1;
channelsPage$ = new BehaviorSubject<number>(1);
channelStatusForm: FormGroup;
defaultStatus = 'open';
constructor(
private lightningApiService: LightningApiService,
private formBuilder: FormBuilder,
) {
this.channelStatusForm = this.formBuilder.group({
status: [this.defaultStatus],
});
}
ngOnInit() {
if (document.body.clientWidth < 670) {
this.paginationSize = 'sm';
this.paginationMaxSize = 3;
}
}
ngOnChanges(): void {
this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false })
this.channels$ = combineLatest([
this.channelsPage$,
this.channelStatusForm.get('status').valueChanges.pipe(startWith(this.defaultStatus))
])
.pipe(
switchMap(([page, status]) =>this.lightningApiService.getChannelsByNodeId$(this.publicKey, (page -1) * this.itemsPerPage, status)),
map((response) => {
return {
channels: response.body,
totalItems: parseInt(response.headers.get('x-total-count'), 10)
};
}),
);
}
pageChange(page: number) {
this.channelsPage$.next(page);
}
}

View File

@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { StateService } from '../services/state.service';
@Injectable({
providedIn: 'root'
})
export class LightningApiService {
private apiBasePath = ''; // network path is /testnet, etc. or '' for mainnet
constructor(
private httpClient: HttpClient,
private stateService: StateService,
) {
this.apiBasePath = ''; // assume mainnet by default
this.stateService.networkChanged$.subscribe((network) => {
if (network === 'bisq' && !this.stateService.env.BISQ_SEPARATE_BACKEND) {
network = '';
}
this.apiBasePath = network ? '/' + network : '';
});
}
getNode$(publicKey: string): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey);
}
getChannel$(shortId: string): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/channels/' + shortId);
}
getChannelsByNodeId$(publicKey: string, index: number = 0, status = 'open'): Observable<any> {
let params = new HttpParams()
.set('public_key', publicKey)
.set('index', index)
.set('status', status)
;
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/channels', { params, observe: 'response' });
}
getLatestStatistics$(): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/statistics/latest');
}
listNodeStats$(publicKey: string): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics');
}
listTopNodes$(): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/top');
}
listStatistics$(): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/statistics');
}
}

View File

@ -0,0 +1,62 @@
<div class="container-xl dashboard-container">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="main-title">
<span i18n="lightning.statistics-title">Network Statistics</span>&nbsp;
</div>
<div class="card-wrapper">
<div class="card" style="height: 123px">
<div class="card-body more-padding">
<app-node-statistics [statistics$]="statistics$"></app-node-statistics>
</div>
</div>
</div>
</div>
<div class="col">
<div class="main-title">
<span i18n="lightning.statistics-title">Channels Statistics</span>&nbsp;
</div>
<div class="card-wrapper">
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
</div>
</div>
</div>
<div class="col">
<div class="card-wrapper">
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Top Capacity Nodes</h5>
<app-nodes-list [nodes$]="nodesByCapacity$"></app-nodes-list>
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Most Connected Nodes</h5>
<app-nodes-list [nodes$]="nodesByChannels$"></app-nodes-list>
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
.dashboard-container {
padding-bottom: 60px;
text-align: center;
margin-top: 0.5rem;
@media (min-width: 992px) {
padding-bottom: 0px;
}
.col {
margin-bottom: 1.5rem;
}
}
.card {
background-color: #1d1f31;
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-title > a {
color: #4a68b9;
}
.card-body {
padding: 1.25rem 1rem 0.75rem 1rem;
}
.card-body.pool-ranking {
padding: 1.25rem 0.25rem 0.75rem 0.25rem;
}
.card-text {
font-size: 22px;
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.more-padding {
padding: 18px;
}
.card-wrapper {
.card {
height: auto !important;
}
.card-body {
display: flex;
flex: inherit;
text-align: center;
flex-direction: column;
justify-content: space-around;
padding: 22px 20px;
}
}
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
.card-text {
font-size: 22px;
}

View File

@ -0,0 +1,41 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { map, share } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { LightningApiService } from '../lightning-api.service';
@Component({
selector: 'app-lightning-dashboard',
templateUrl: './lightning-dashboard.component.html',
styleUrls: ['./lightning-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LightningDashboardComponent implements OnInit {
nodesByCapacity$: Observable<any>;
nodesByChannels$: Observable<any>;
statistics$: Observable<any>;
constructor(
private lightningApiService: LightningApiService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle($localize`Lightning Dashboard`);
const sharedObservable = this.lightningApiService.listTopNodes$().pipe(share());
this.nodesByCapacity$ = sharedObservable
.pipe(
map((object) => object.topByCapacity),
);
this.nodesByChannels$ = sharedObservable
.pipe(
map((object) => object.topByChannels),
);
this.statistics$ = this.lightningApiService.getLatestStatistics$();
}
}

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { WebsocketService } from 'src/app/services/websocket.service';
@Component({
selector: 'app-lightning-wrapper',
templateUrl: './lightning-wrapper.component.html',
styleUrls: ['./lightning-wrapper.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LightningWrapperComponent implements OnInit {
constructor(
private websocketService: WebsocketService,
) { }
ngOnInit() {
this.websocketService.want(['blocks']);
}
}

View File

@ -0,0 +1,44 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module';
import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component';
import { LightningApiService } from './lightning-api.service';
import { NodesListComponent } from './nodes-list/nodes-list.component';
import { RouterModule } from '@angular/router';
import { NodeStatisticsComponent } from './node-statistics/node-statistics.component';
import { NodeComponent } from './node/node.component';
import { LightningRoutingModule } from './lightning.routing.module';
import { ChannelsListComponent } from './channels-list/channels-list.component';
import { ChannelComponent } from './channel/channel.component';
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
import { ChannelBoxComponent } from './channel/channel-box/channel-box.component';
import { ClosingTypeComponent } from './channel/closing-type/closing-type.component';
import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component';
import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component';
import { GraphsModule } from '../graphs/graphs.module';
@NgModule({
declarations: [
LightningDashboardComponent,
NodesListComponent,
NodeStatisticsComponent,
NodeStatisticsChartComponent,
NodeComponent,
ChannelsListComponent,
ChannelComponent,
LightningWrapperComponent,
ChannelBoxComponent,
ClosingTypeComponent,
LightningStatisticsChartComponent,
],
imports: [
CommonModule,
SharedModule,
RouterModule,
LightningRoutingModule,
GraphsModule,
],
providers: [
LightningApiService,
]
})
export class LightningModule { }

View File

@ -0,0 +1,41 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LightningDashboardComponent } from './lightning-dashboard/lightning-dashboard.component';
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
import { NodeComponent } from './node/node.component';
import { ChannelComponent } from './channel/channel.component';
const routes: Routes = [
{
path: '',
component: LightningWrapperComponent,
children: [
{
path: '',
component: LightningDashboardComponent,
},
{
path: 'node/:public_key',
component: NodeComponent,
},
{
path: 'channel/:short_id',
component: ChannelComponent,
},
{
path: '**',
redirectTo: ''
}
]
},
{
path: '**',
redirectTo: ''
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LightningRoutingModule { }

View File

@ -0,0 +1,8 @@
<div class="full-container">
<div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>

View File

@ -0,0 +1,129 @@
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
padding: 0px 15px;
width: 100%;
/* min-height: 500px; */
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
};
}
/*
.chart {
width: 100%;
height: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
*/
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.formRadioGroup {
margin-top: 6px;
display: flex;
flex-direction: column;
@media (min-width: 991px) {
position: relative;
top: -65px;
}
@media (min-width: 830px) and (max-width: 991px) {
position: relative;
top: 0px;
}
@media (min-width: 830px) {
flex-direction: row;
float: right;
margin-top: 0px;
}
.btn-sm {
font-size: 9px;
@media (min-width: 830px) {
font-size: 14px;
}
}
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}

View File

@ -0,0 +1,287 @@
import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { EChartsOption } from 'echarts';
import { Observable } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { formatNumber } from '@angular/common';
import { FormGroup } from '@angular/forms';
import { StorageService } from 'src/app/services/storage.service';
import { download } from 'src/app/shared/graphs.utils';
import { LightningApiService } from '../lightning-api.service';
import { ActivatedRoute, ParamMap } from '@angular/router';
@Component({
selector: 'app-node-statistics-chart',
templateUrl: './node-statistics-chart.component.html',
styleUrls: ['./node-statistics-chart.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
})
export class NodeStatisticsChartComponent implements OnInit {
@Input() publicKey: string;
@Input() right: number | string = 65;
@Input() left: number | string = 55;
@Input() widget = false;
miningWindowPreference: string;
radioGroupForm: FormGroup;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
@HostBinding('attr.dir') dir = 'ltr';
blockSizesWeightsObservable$: Observable<any>;
isLoading = true;
formatNumber = formatNumber;
timespan = '';
chartInstance: any = undefined;
constructor(
@Inject(LOCALE_ID) public locale: string,
private lightningApiService: LightningApiService,
private storageService: StorageService,
private activatedRoute: ActivatedRoute,
) {
}
ngOnInit(): void {
this.activatedRoute.paramMap
.pipe(
switchMap((params: ParamMap) => {
this.isLoading = true;
return this.lightningApiService.listNodeStats$(params.get('public_key'))
.pipe(
tap((data) => {
this.prepareChartOptions({
channels: data.map(val => [val.added * 1000, val.channels]),
capacity: data.map(val => [val.added * 1000, val.capacity]),
});
this.isLoading = false;
}),
);
}),
).subscribe(() => {
});
}
prepareChartOptions(data) {
let title: object;
if (data.channels.length === 0) {
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: `Loading`,
left: 'center',
top: 'center'
};
}
this.chartOptions = {
title: title,
animation: false,
color: [
'#FDD835',
'#D81B60',
],
grid: {
top: 30,
bottom: 70,
right: this.right,
left: this.left,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
align: 'left',
},
borderColor: '#000',
formatter: (ticks) => {
let sizeString = '';
let weightString = '';
for (const tick of ticks) {
if (tick.seriesIndex === 0) { // Channels
sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
} else if (tick.seriesIndex === 1) { // Capacity
weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`;
}
}
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
const tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>
<span>${sizeString}</span><br>
<span>${weightString}</span>`;
return tooltip;
}
},
xAxis: data.channels.length === 0 ? undefined : {
type: 'time',
splitNumber: this.isMobile() ? 5 : 10,
axisLabel: {
hideOverlap: true,
}
},
legend: data.channels.length === 0 ? undefined : {
padding: 10,
data: [
{
name: 'Channels',
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Capacity',
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
],
selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? {
'Channels': true,
'Capacity': true,
}
},
yAxis: data.channels.length === 0 ? undefined : [
{
min: (value) => {
return value.min * 0.9;
},
type: 'value',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${Math.round(val)}`;
}
},
splitLine: {
lineStyle: {
type: 'dotted',
color: '#ffffff66',
opacity: 0.25,
}
},
},
{
min: (value) => {
return value.min * 0.9;
},
type: 'value',
position: 'right',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${val / 100000000} BTC`;
}
},
splitLine: {
show: false,
}
}
],
series: data.channels.length === 0 ? [] : [
{
zlevel: 1,
name: 'Channels',
showSymbol: false,
symbol: 'none',
data: data.channels,
type: 'line',
step: 'middle',
lineStyle: {
width: 2,
},
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
type: 'solid',
color: '#ffffff66',
opacity: 1,
width: 1,
},
data: [{
yAxis: 1,
label: {
position: 'end',
show: true,
color: '#ffffff',
formatter: `1 MB`
}
}],
}
},
{
zlevel: 0,
yAxisIndex: 1,
name: 'Capacity',
showSymbol: false,
symbol: 'none',
stack: 'Total',
data: data.capacity,
areaStyle: {},
type: 'line',
step: 'middle',
}
],
};
}
onChartInit(ec) {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
this.chartInstance.on('legendselectchanged', (e) => {
this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected));
});
}
isMobile() {
return (window.innerWidth <= 767.98);
}
onSaveChart() {
// @ts-ignore
const prevBottom = this.chartOptions.grid.bottom;
const now = new Date();
// @ts-ignore
this.chartOptions.grid.bottom = 40;
this.chartOptions.backgroundColor = '#11131f';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
}), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
// @ts-ignore
this.chartOptions.grid.bottom = prevBottom;
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
}
}

View File

@ -0,0 +1,76 @@
<div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward">
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="mining.average-fee">Capacity</h5>
<div class="card-text" i18n-ngbTooltip="mining.average-fee"
ngbTooltip="Percentage change past week" placement="bottom">
<app-amount [satoshis]="statistics.latest?.total_capacity" digitsInfo="1.2-2"></app-amount>
<span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest.total_capacity" [previous]="statistics.previous.total_capacity"></app-change>
</span>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.rewards">Nodes</h5>
<div class="card-text" i18n-ngbTooltip="mining.rewards-desc"
ngbTooltip="Percentage change past week" placement="bottom">
<div class="fee-text">
{{ statistics.latest?.node_count || 0 | number }}
</div>
<span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest.node_count" [previous]="statistics.previous.node_count"></app-change>
</span>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.rewards-per-tx">Channels</h5>
<div class="card-text" i18n-ngbTooltip="mining.rewards-per-tx-desc"
ngbTooltip="Percentage change past week" placement="bottom">
<div class="fee-text">
{{ statistics.latest?.channel_count || 0 | number }}
</div>
<span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest.channel_count" [previous]="statistics.previous.channel_count"></app-change>
</span>
</div>
</div>
<!--
<div class="item">
<h5 class="card-title" i18n="mining.average-fee">Average Channel</h5>
<div class="card-text" i18n-ngbTooltip="mining.average-fee"
ngbTooltip="Fee paid on average for each transaction in the past 144 blocks" placement="bottom">
<app-amount [satoshis]="statistics.latest.average_channel_size" digitsInfo="1.2-3"></app-amount>
<span class="fiat">
<app-change [current]="statistics.latest.average_channel_size" [previous]="statistics.previous.average_channel_size"></app-change>
</span>
</div>
</div>
-->
</div>
</div>
<ng-template #loadingReward>
<div class="fee-estimation-container loading-container">
<div class="item">
<h5 class="card-title" i18n="mining.rewards">Nodes</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.rewards-per-tx">Channels</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.average-fee">Average Channel</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,85 @@
.card-title {
color: #4a68b9;
font-size: 10px;
margin-bottom: 4px;
font-size: 1rem;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
display: inline-flex;
}
.green-color {
display: block;
}
}
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 150px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
&:first-child{
display: none;
@media (min-width: 485px) {
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
&:last-child {
margin-bottom: 0;
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.loading-container {
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}

View File

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
@Component({
selector: 'app-node-statistics',
templateUrl: './node-statistics.component.html',
styleUrls: ['./node-statistics.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeStatisticsComponent implements OnInit {
@Input() statistics$: Observable<any>;
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,100 @@
<div class="container-xl" *ngIf="(node$ | async) as node">
<div class="title-container mb-2">
<h1 class="mb-0">{{ node.alias }}</h1>
<span class="tx-link">
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.public_key | shortenString : 12 }}</a>
<app-clipboard [text]="node.public_key"></app-clipboard>
</span>
</div>
<div class="clearfix"></div>
<div class="box">
<div class="row">
<div class="col-md">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="address.total-received">Total capacity</td>
<td>
<app-sats [satoshis]="node.capacity"></app-sats><app-fiat [value]="node.capacity" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Total channels</td>
<td>
{{ node.channel_count }}
</td>
</tr>
<tr>
<td i18n="address.total-received">Average channel size</td>
<td>
<app-sats [satoshis]="node.channels_capacity_avg"></app-sats><app-fiat [value]="node.channels_capacity_avg" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col-md">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td i18n="address.total-received">First seen</td>
<td>
<app-timestamp [dateString]="node.first_seen"></app-timestamp>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Last update</td>
<td>
<app-timestamp [dateString]="node.updated_at"></app-timestamp>
</td>
</tr>
<tr>
<td i18n="address.balance">Color</td>
<td><div [ngStyle]="{'color': node.color}">{{ node.color }}</div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<div class="input-group mb-3" *ngIf="node.socketsObject.length">
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown" *ngIf="node.socketsObject.length > 1; else noDropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" aria-expanded="false" ngbDropdownAnchor (focus)="myDrop.open()"><div class="dropdownLabel">{{ node.socketsObject[selectedSocketIndex].label }}</div></button>
<div ngbDropdownMenu aria-labelledby="dropdownManual">
<button *ngFor="let socket of node.socketsObject; let i = index;" ngbDropdownItem (click)="changeSocket(i)">{{ socket.label }}</button>
</div>
</div>
<ng-template #noDropdown>
<span class="input-group-text" id="basic-addon3">{{ node.socketsObject[selectedSocketIndex].label }}</span>
</ng-template>
<input type="text" class="form-control" aria-label="Text input with dropdown button" [value]="node.socketsObject[selectedSocketIndex].socket">
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04" (mouseover)="qrCodeVisible = true" (mouseout)="qrCodeVisible = false">
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true"></fa-icon>
<div class="qr-wrapper" [hidden]="!qrCodeVisible">
<app-qrcode [size]="200" [data]="node.socketsObject[selectedSocketIndex].socket"></app-qrcode>
</div>
</button>
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04">
<app-clipboard [text]="node.socketsObject[selectedSocketIndex].socket"></app-clipboard>
</button>
</div>
<br>
<app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
<br>
<app-channels-list [publicKey]="node.public_key"></app-channels-list>
</div>
<br>

View File

@ -0,0 +1,60 @@
.title-container {
display: flex;
flex-direction: row;
@media (max-width: 768px) {
flex-direction: column;
}
}
.tx-link {
display: flex;
flex-grow: 1;
@media (min-width: 650px) {
align-self: end;
margin-left: 15px;
margin-top: 0px;
margin-bottom: -3px;
}
@media (min-width: 768px) {
margin-bottom: 4px;
top: 1px;
position: relative;
}
@media (max-width: 768px) {
order: 2;
}
}
.qr-wrapper {
background-color: #FFF;
padding: 10px;
padding-bottom: 5px;
display: inline-block;
position: absolute;
bottom: 50px;
left: -175px;
z-index: 100;
}
.dropdownLabel {
min-width: 50px;
display: inline-block;
}
#inputGroupFileAddon04 {
position: relative;
}
app-fiat {
display: block;
font-size: 13px;
@media (min-width: 768px) {
font-size: 14px;
display: inline-block;
margin-left: 10px;
}
}

View File

@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { LightningApiService } from '../lightning-api.service';
@Component({
selector: 'app-node',
templateUrl: './node.component.html',
styleUrls: ['./node.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeComponent implements OnInit {
node$: Observable<any>;
statistics$: Observable<any>;
publicKey$: Observable<string>;
selectedSocketIndex = 0;
qrCodeVisible = false;
constructor(
private lightningApiService: LightningApiService,
private activatedRoute: ActivatedRoute,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.node$ = this.activatedRoute.paramMap
.pipe(
switchMap((params: ParamMap) => {
return this.lightningApiService.getNode$(params.get('public_key'));
}),
map((node) => {
this.seoService.setTitle(`Node: ${node.alias}`);
const socketsObject = [];
for (const socket of node.sockets.split(',')) {
if (socket === '') {
continue;
}
let label = '';
if (socket.match(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/)) {
label = 'IPv4';
} else if (socket.indexOf('[') > -1) {
label = 'IPv6';
} else if (socket.indexOf('onion') > -1) {
label = 'Tor';
}
socketsObject.push({
label: label,
socket: node.public_key + '@' + socket,
});
}
node.socketsObject = socketsObject;
return node;
}),
);
}
changeSocket(index: number) {
this.selectedSocketIndex = index;
}
}

View File

@ -0,0 +1,39 @@
<div style="min-height: 295px">
<table class="table table-borderless">
<thead>
<th class="alias text-left" i18n="nodes.alias">Alias</th>
<th class="capacity text-right" i18n="node.capacity">Capacity</th>
<th class="channels text-right" i18n="node.channels">Channels</th>
</thead>
<tbody *ngIf="nodes$ | async as nodes; else skeleton">
<tr *ngFor="let node of nodes; let i = index;">
<td class="alias text-left">
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.alias }}</a>
</td>
<td class="capacity text-right">
<app-amount [satoshis]="node.capacity" digitsInfo="1.2-2"></app-amount>
</td>
<td class="channels text-right">
{{ node.channels | number }}
</td>
</tr>
</tbody>
<ng-template #skeleton>
<tbody>
<tr *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<td class="alias text-left">
<span class="skeleton-loader"></span>
</td>
<td class="capacity text-right">
<span class="skeleton-loader"></span>
</td>
<td class="channels text-right">
<span class="skeleton-loader"></span>
</td>
</tr>
</tbody>
</ng-template>
</table>
</div>

View File

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
@Component({
selector: 'app-nodes-list',
templateUrl: './nodes-list.component.html',
styleUrls: ['./nodes-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodesListComponent implements OnInit {
@Input() nodes$: Observable<any>;
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,32 @@
<div [class]="widget === false ? 'full-container' : ''">
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
<span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
<div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>
<ng-template #loadingStats>
<div class="pool-distribution">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Hashrate</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Difficulty</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,135 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
padding: 0px 15px;
width: 100%;
min-height: 500px;
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
};
}
.chart {
width: 100%;
height: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.formRadioGroup {
margin-top: 6px;
display: flex;
flex-direction: column;
@media (min-width: 991px) {
position: relative;
top: -65px;
}
@media (min-width: 830px) and (max-width: 991px) {
position: relative;
top: 0px;
}
@media (min-width: 830px) {
flex-direction: row;
float: right;
margin-top: 0px;
}
.btn-sm {
font-size: 9px;
@media (min-width: 830px) {
font-size: 14px;
}
}
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}

View File

@ -0,0 +1,299 @@
import { Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs';
import { startWith, switchMap, tap } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms';
import { StorageService } from 'src/app/services/storage.service';
import { MiningService } from 'src/app/services/mining.service';
import { download } from 'src/app/shared/graphs.utils';
import { LightningApiService } from '../lightning-api.service';
@Component({
selector: 'app-lightning-statistics-chart',
templateUrl: './lightning-statistics-chart.component.html',
styleUrls: ['./lightning-statistics-chart.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
})
export class LightningStatisticsChartComponent implements OnInit {
@Input() right: number | string = 65;
@Input() left: number | string = 55;
@Input() widget = false;
miningWindowPreference: string;
radioGroupForm: FormGroup;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
@HostBinding('attr.dir') dir = 'ltr';
blockSizesWeightsObservable$: Observable<any>;
isLoading = true;
formatNumber = formatNumber;
timespan = '';
chartInstance: any = undefined;
constructor(
@Inject(LOCALE_ID) public locale: string,
private seoService: SeoService,
private lightningApiService: LightningApiService,
private formBuilder: FormBuilder,
private storageService: StorageService,
private miningService: MiningService,
) {
}
ngOnInit(): void {
let firstRun = true;
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.radioGroupForm.get('dateSpan').valueChanges
.pipe(
startWith(this.miningWindowPreference),
switchMap((timespan) => {
this.timespan = timespan;
if (!firstRun) {
this.storageService.setValue('miningWindowPreference', timespan);
}
firstRun = false;
this.miningWindowPreference = timespan;
this.isLoading = true;
return this.lightningApiService.listStatistics$()
.pipe(
tap((data) => {
this.prepareChartOptions({
nodes: data.map(val => [val.added * 1000, val.node_count]),
capacity: data.map(val => [val.added * 1000, val.total_capacity]),
});
this.isLoading = false;
}),
);
}),
).subscribe(() => {
});
}
prepareChartOptions(data) {
let title: object;
if (data.nodes.length === 0) {
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: `Indexing in progess`,
left: 'center',
top: 'center'
};
}
this.chartOptions = {
title: title,
animation: false,
color: [
'#FDD835',
'#D81B60',
],
grid: {
top: 30,
bottom: 70,
right: this.right,
left: this.left,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
align: 'left',
},
borderColor: '#000',
formatter: (ticks) => {
let sizeString = '';
let weightString = '';
for (const tick of ticks) {
if (tick.seriesIndex === 0) { // Nodes
sizeString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
} else if (tick.seriesIndex === 1) { // Capacity
weightString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100000000, this.locale, '1.0-0')} BTC`;
}
}
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
let tooltip = `<b style="color: white; margin-left: 18px">${date}</b><br>
<span>${sizeString}</span><br>
<span>${weightString}</span>`;
return tooltip;
}
},
xAxis: data.nodes.length === 0 ? undefined : {
type: 'time',
splitNumber: this.isMobile() ? 5 : 10,
axisLabel: {
hideOverlap: true,
}
},
legend: data.nodes.length === 0 ? undefined : {
padding: 10,
data: [
{
name: 'Nodes',
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Capacity',
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
],
selected: JSON.parse(this.storageService.getValue('sizes_ln_legend')) ?? {
'Nodes': true,
'Capacity': true,
}
},
yAxis: data.nodes.length === 0 ? undefined : [
{
min: (value) => {
return value.min * 0.9;
},
type: 'value',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${Math.round(val)}`;
}
},
splitLine: {
lineStyle: {
type: 'dotted',
color: '#ffffff66',
opacity: 0.25,
}
},
},
{
min: (value) => {
return value.min * 0.9;
},
type: 'value',
position: 'right',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${val / 100000000} BTC`;
}
},
splitLine: {
show: false,
}
}
],
series: data.nodes.length === 0 ? [] : [
{
zlevel: 1,
name: 'Nodes',
showSymbol: false,
symbol: 'none',
data: data.nodes,
type: 'line',
lineStyle: {
width: 2,
},
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
type: 'solid',
color: '#ffffff66',
opacity: 1,
width: 1,
},
data: [{
yAxis: 1,
label: {
position: 'end',
show: true,
color: '#ffffff',
formatter: `1 MB`
}
}],
}
},
{
zlevel: 0,
yAxisIndex: 1,
name: 'Capacity',
showSymbol: false,
symbol: 'none',
stack: 'Total',
data: data.capacity,
areaStyle: {},
type: 'line',
}
],
};
}
onChartInit(ec) {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
this.chartInstance.on('legendselectchanged', (e) => {
this.storageService.setValue('sizes_ln_legend', JSON.stringify(e.selected));
});
}
isMobile() {
return (window.innerWidth <= 767.98);
}
onSaveChart() {
// @ts-ignore
const prevBottom = this.chartOptions.grid.bottom;
const now = new Date();
// @ts-ignore
this.chartOptions.grid.bottom = 40;
this.chartOptions.backgroundColor = '#11131f';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
}), `block-sizes-weights-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
// @ts-ignore
this.chartOptions.grid.bottom = prevBottom;
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
}
}

View File

@ -231,4 +231,18 @@ export class ApiService {
getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
}
getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: 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 });
}
lightningSearch$(searchText: string): Observable<any[]> {
let params = new HttpParams().set('searchText', searchText);
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
}
}

View File

@ -37,6 +37,7 @@ export interface Env {
LIQUID_WEBSITE_URL: string;
BISQ_WEBSITE_URL: string;
MINING_DASHBOARD: boolean;
LIGHTNING: boolean;
}
const defaultEnv: Env = {
@ -60,7 +61,8 @@ const defaultEnv: Env = {
'MEMPOOL_WEBSITE_URL': 'https://mempool.space',
'LIQUID_WEBSITE_URL': 'https://liquid.network',
'BISQ_WEBSITE_URL': 'https://bisq.markets',
'MINING_DASHBOARD': true
'MINING_DASHBOARD': true,
'LIGHTNING': false,
};
@Injectable({

View File

@ -0,0 +1,5 @@
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number : digitsInfo }}
<span class="symbol"><ng-template [ngIf]="network === 'liquid'">L-</ng-template>
<ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template>
<ng-template [ngIf]="network === 'testnet'">t-</ng-template>
<ng-template [ngIf]="network === 'signet'">s-</ng-template>sats</span>

View File

@ -0,0 +1,32 @@
import { Component, Input, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { StateService } from '../../../services/state.service';
@Component({
selector: 'app-sats',
templateUrl: './sats.component.html',
styleUrls: ['./sats.component.scss']
})
export class SatsComponent implements OnInit {
@Input() satoshis: number;
@Input() digitsInfo = '1.0-0';
@Input() addPlus = false;
network = '';
stateSubscription: Subscription;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.stateSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network);
}
ngOnDestroy() {
if (this.stateSubscription) {
this.stateSubscription.unsubscribe();
}
}
}

View File

@ -0,0 +1,4 @@
&lrm;{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
</div>

View File

@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-timestamp',
templateUrl: './timestamp.component.html',
styleUrls: ['./timestamp.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TimestampComponent implements OnChanges {
@Input() unixTime: number;
@Input() dateString: string;
seconds: number;
constructor() { }
ngOnChanges(): void {
if (this.unixTime) {
this.seconds = this.unixTime;
} else if (this.dateString) {
this.seconds = new Date(this.dateString).getTime() / 1000
}
}
}

View File

@ -4,7 +4,7 @@ import { NgbCollapse, NgbCollapseModule, NgbRadioGroup, NgbTypeaheadModule } fro
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload } from '@fortawesome/free-solid-svg-icons';
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MasterPageComponent } from '../components/master-page/master-page.component';
import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component';
@ -40,7 +40,6 @@ import { BlockchainBlocksComponent } from '../components/blockchain-blocks/block
import { AmountComponent } from '../components/amount/amount.component';
import { RouterModule } from '@angular/router';
import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe';
import { StartComponent } from '../components/start/start.component';
import { TransactionComponent } from '../components/transaction/transaction.component';
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
@ -74,6 +73,10 @@ import { DataCyDirective } from '../data-cy.directive';
import { LoadingIndicatorComponent } from '../components/loading-indicator/loading-indicator.component';
import { IndexingProgressComponent } from '../components/indexing-progress/indexing-progress.component';
import { SvgImagesComponent } from '../components/svg-images/svg-images.component';
import { ChangeComponent } from '../components/change/change.component';
import { SatsComponent } from './components/sats/sats.component';
import { SearchResultsComponent } from '../components/search-form/search-results/search-results.component';
import { TimestampComponent } from './components/timestamp/timestamp.component';
@NgModule({
declarations: [
@ -104,7 +107,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
MempoolBlocksComponent,
BlockchainBlocksComponent,
AmountComponent,
AboutComponent,
MasterPageComponent,
BisqMasterPageComponent,
@ -142,6 +144,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
LoadingIndicatorComponent,
IndexingProgressComponent,
SvgImagesComponent,
ChangeComponent,
SatsComponent,
SearchResultsComponent,
TimestampComponent,
],
imports: [
CommonModule,
@ -163,6 +169,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
NoSanitizePipe,
ShortenStringPipe,
CapAddressPipe,
AmountShortenerPipe,
],
exports: [
RouterModule,
@ -203,7 +210,6 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
MempoolBlocksComponent,
BlockchainBlocksComponent,
AmountComponent,
StartComponent,
TransactionComponent,
BlockComponent,
@ -237,6 +243,10 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
LoadingIndicatorComponent,
IndexingProgressComponent,
SvgImagesComponent,
ChangeComponent,
SatsComponent,
SearchResultsComponent,
TimestampComponent,
]
})
export class SharedModule {
@ -275,5 +285,6 @@ export class SharedModule {
library.addIcons(faBook);
library.addIcons(faListUl);
library.addIcons(faDownload);
library.addIcons(faQrcode);
}
}