Merge branch 'master' into dependabot/npm_and_yarn/frontend/tinyify-3.1.0
This commit is contained in:
		
						commit
						291277f299
					
				@ -20,7 +20,8 @@
 | 
				
			|||||||
    "EXTERNAL_MAX_RETRY": 1,
 | 
					    "EXTERNAL_MAX_RETRY": 1,
 | 
				
			||||||
    "EXTERNAL_RETRY_INTERVAL": 0,
 | 
					    "EXTERNAL_RETRY_INTERVAL": 0,
 | 
				
			||||||
    "USER_AGENT": "mempool",
 | 
					    "USER_AGENT": "mempool",
 | 
				
			||||||
    "STDOUT_LOG_MIN_PRIORITY": "debug"
 | 
					    "STDOUT_LOG_MIN_PRIORITY": "debug",
 | 
				
			||||||
 | 
					    "AUTOMATIC_BLOCK_REINDEXING": false
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "CORE_RPC": {
 | 
					  "CORE_RPC": {
 | 
				
			||||||
    "HOST": "127.0.0.1",
 | 
					    "HOST": "127.0.0.1",
 | 
				
			||||||
@ -66,6 +67,15 @@
 | 
				
			|||||||
    "ENABLED": false,
 | 
					    "ENABLED": false,
 | 
				
			||||||
    "DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
 | 
					    "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": {
 | 
					  "SOCKS5PROXY": {
 | 
				
			||||||
    "ENABLED": false,
 | 
					    "ENABLED": false,
 | 
				
			||||||
    "USE_ONION": true,
 | 
					    "USE_ONION": true,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										994
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										994
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "mempool-backend",
 | 
					  "name": "mempool-backend",
 | 
				
			||||||
  "version": "2.4.1-dev",
 | 
					  "version": "2.5.0-dev",
 | 
				
			||||||
  "description": "Bitcoin mempool visualizer and blockchain explorer backend",
 | 
					  "description": "Bitcoin mempool visualizer and blockchain explorer backend",
 | 
				
			||||||
  "license": "GNU Affero General Public License v3.0",
 | 
					  "license": "GNU Affero General Public License v3.0",
 | 
				
			||||||
  "homepage": "https://mempool.space",
 | 
					  "homepage": "https://mempool.space",
 | 
				
			||||||
@ -34,8 +34,10 @@
 | 
				
			|||||||
    "@types/node": "^16.11.41",
 | 
					    "@types/node": "^16.11.41",
 | 
				
			||||||
    "axios": "~0.27.2",
 | 
					    "axios": "~0.27.2",
 | 
				
			||||||
    "bitcoinjs-lib": "6.0.1",
 | 
					    "bitcoinjs-lib": "6.0.1",
 | 
				
			||||||
 | 
					    "bolt07": "^1.8.1",
 | 
				
			||||||
    "crypto-js": "^4.0.0",
 | 
					    "crypto-js": "^4.0.0",
 | 
				
			||||||
    "express": "^4.18.0",
 | 
					    "express": "^4.18.0",
 | 
				
			||||||
 | 
					    "lightning": "^5.16.3",
 | 
				
			||||||
    "mysql2": "2.3.3",
 | 
					    "mysql2": "2.3.3",
 | 
				
			||||||
    "node-worker-threads-pool": "^1.5.1",
 | 
					    "node-worker-threads-pool": "^1.5.1",
 | 
				
			||||||
    "socks-proxy-agent": "~7.0.0",
 | 
					    "socks-proxy-agent": "~7.0.0",
 | 
				
			||||||
 | 
				
			|||||||
@ -13,6 +13,7 @@ export interface AbstractBitcoinApi {
 | 
				
			|||||||
  $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
 | 
					  $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
 | 
				
			||||||
  $getAddressPrefix(prefix: string): string[];
 | 
					  $getAddressPrefix(prefix: string): string[];
 | 
				
			||||||
  $sendRawTransaction(rawTransaction: string): Promise<string>;
 | 
					  $sendRawTransaction(rawTransaction: string): Promise<string>;
 | 
				
			||||||
 | 
					  $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
 | 
				
			||||||
  $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
 | 
					  $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
 | 
				
			||||||
  $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
 | 
					  $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -130,6 +130,16 @@ class BitcoinApi implements AbstractBitcoinApi {
 | 
				
			|||||||
    return this.bitcoindClient.sendRawTransaction(rawTransaction);
 | 
					    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[]> {
 | 
					  async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
 | 
				
			||||||
    const outSpends: IEsploraApi.Outspend[] = [];
 | 
					    const outSpends: IEsploraApi.Outspend[] = [];
 | 
				
			||||||
    const tx = await this.$getRawTransaction(txId, true, false);
 | 
					    const tx = await this.$getRawTransaction(txId, true, false);
 | 
				
			||||||
@ -195,7 +205,9 @@ class BitcoinApi implements AbstractBitcoinApi {
 | 
				
			|||||||
        sequence: vin.sequence,
 | 
					        sequence: vin.sequence,
 | 
				
			||||||
        txid: vin.txid || '',
 | 
					        txid: vin.txid || '',
 | 
				
			||||||
        vout: vin.vout || 0,
 | 
					        vout: vin.vout || 0,
 | 
				
			||||||
        witness: vin.txinwitness,
 | 
					        witness: vin.txinwitness || [],
 | 
				
			||||||
 | 
					        inner_redeemscript_asm: '',
 | 
				
			||||||
 | 
					        inner_witnessscript_asm: '',
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -25,10 +25,10 @@ export namespace IEsploraApi {
 | 
				
			|||||||
    is_coinbase: boolean;
 | 
					    is_coinbase: boolean;
 | 
				
			||||||
    scriptsig: string;
 | 
					    scriptsig: string;
 | 
				
			||||||
    scriptsig_asm: string;
 | 
					    scriptsig_asm: string;
 | 
				
			||||||
    inner_redeemscript_asm?: string;
 | 
					    inner_redeemscript_asm: string;
 | 
				
			||||||
    inner_witnessscript_asm?: string;
 | 
					    inner_witnessscript_asm: string;
 | 
				
			||||||
    sequence: any;
 | 
					    sequence: any;
 | 
				
			||||||
    witness?: string[];
 | 
					    witness: string[];
 | 
				
			||||||
    prevout: Vout | null;
 | 
					    prevout: Vout | null;
 | 
				
			||||||
    // Elements
 | 
					    // Elements
 | 
				
			||||||
    is_pegin?: boolean;
 | 
					    is_pegin?: boolean;
 | 
				
			||||||
 | 
				
			|||||||
@ -66,6 +66,11 @@ class ElectrsApi implements AbstractBitcoinApi {
 | 
				
			|||||||
    throw new Error('Method not implemented.');
 | 
					    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[]> {
 | 
					  $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
 | 
				
			||||||
    return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
 | 
					    return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
 | 
				
			||||||
      .then((response) => response.data);
 | 
					      .then((response) => response.data);
 | 
				
			||||||
 | 
				
			|||||||
@ -579,17 +579,13 @@ class Blocks {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
 | 
					  public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
 | 
				
			||||||
    let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight();
 | 
					    let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight();
 | 
				
			||||||
    const returnBlocks: BlockExtended[] = [];
 | 
					    const returnBlocks: BlockExtended[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (currentHeight < 0) {
 | 
					    if (currentHeight < 0) {
 | 
				
			||||||
      return returnBlocks;
 | 
					      return returnBlocks;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (currentHeight === 0 && Common.indexingEnabled()) {
 | 
					 | 
				
			||||||
      currentHeight = await blocksRepository.$mostRecentBlockHeight();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Check if block height exist in local cache to skip the hash lookup
 | 
					    // Check if block height exist in local cache to skip the hash lookup
 | 
				
			||||||
    const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight);
 | 
					    const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight);
 | 
				
			||||||
    let startFromHash: string | null = null;
 | 
					    let startFromHash: string | null = null;
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@ import logger from '../logger';
 | 
				
			|||||||
import { Common } from './common';
 | 
					import { Common } from './common';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class DatabaseMigration {
 | 
					class DatabaseMigration {
 | 
				
			||||||
  private static currentVersion = 24;
 | 
					  private static currentVersion = 27;
 | 
				
			||||||
  private queryTimeout = 120000;
 | 
					  private queryTimeout = 120000;
 | 
				
			||||||
  private statisticsAddedIndexed = false;
 | 
					  private statisticsAddedIndexed = false;
 | 
				
			||||||
  private uniqueLogs: string[] = [];
 | 
					  private uniqueLogs: string[] = [];
 | 
				
			||||||
@ -248,6 +248,32 @@ class DatabaseMigration {
 | 
				
			|||||||
        await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
 | 
					        await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
 | 
				
			||||||
        await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('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'));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (databaseSchemaVersion < 26 && isBitcoin === true) {
 | 
				
			||||||
 | 
					        this.uniqueLog(logger.notice, `'lightning_stats' table has been truncated. Will re-generate historical data from scratch.`);
 | 
				
			||||||
 | 
					        await this.$executeQuery(`TRUNCATE lightning_stats`);
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (databaseSchemaVersion < 27 && isBitcoin === true) {
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					        await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    } catch (e) {
 | 
					    } catch (e) {
 | 
				
			||||||
      throw e;
 | 
					      throw e;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -572,6 +598,82 @@ class DatabaseMigration {
 | 
				
			|||||||
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
 | 
					      ) 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;`;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private getCreateBlocksAuditsTableQuery(): string {
 | 
					  private getCreateBlocksAuditsTableQuery(): string {
 | 
				
			||||||
    return `CREATE TABLE IF NOT EXISTS blocks_audits (
 | 
					    return `CREATE TABLE IF NOT EXISTS blocks_audits (
 | 
				
			||||||
      time timestamp NOT NULL,
 | 
					      time timestamp NOT NULL,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										227
									
								
								backend/src/api/explorer/channels.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										227
									
								
								backend/src/api/explorer/channels.api.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,227 @@
 | 
				
			|||||||
 | 
					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 $getChannelsStats(): Promise<any> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      // Feedback from zerofeerouting:
 | 
				
			||||||
 | 
					      // "I would argue > 5000ppm can be ignored. Channels charging more than .5% fee are ignored by CLN for example."
 | 
				
			||||||
 | 
					      const ignoredFeeRateThreshold = 5000;
 | 
				
			||||||
 | 
					      const ignoredBaseFeeThreshold = 5000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Capacity
 | 
				
			||||||
 | 
					      let query = `SELECT AVG(capacity) AS avgCapacity FROM channels WHERE status = 1 ORDER BY capacity`;
 | 
				
			||||||
 | 
					      const [avgCapacity]: any = await DB.query(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      query = `SELECT capacity FROM channels WHERE status = 1 ORDER BY capacity`;
 | 
				
			||||||
 | 
					      let [capacity]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      capacity = capacity.map(capacity => capacity.capacity);
 | 
				
			||||||
 | 
					      const medianCapacity = capacity[Math.floor(capacity.length / 2)];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Fee rates
 | 
				
			||||||
 | 
					      query = `SELECT node1_fee_rate FROM channels WHERE node1_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
 | 
				
			||||||
 | 
					      let [feeRates1]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      feeRates1 = feeRates1.map(rate => rate.node1_fee_rate);
 | 
				
			||||||
 | 
					      query = `SELECT node2_fee_rate FROM channels WHERE node2_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
 | 
				
			||||||
 | 
					      let [feeRates2]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      feeRates2 = feeRates2.map(rate => rate.node2_fee_rate);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let feeRates = (feeRates1.concat(feeRates2)).sort((a, b) => a - b);
 | 
				
			||||||
 | 
					      let avgFeeRate = 0;
 | 
				
			||||||
 | 
					      for (const rate of feeRates) {
 | 
				
			||||||
 | 
					        avgFeeRate += rate; 
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      avgFeeRate /= feeRates.length;
 | 
				
			||||||
 | 
					      const medianFeeRate = feeRates[Math.floor(feeRates.length / 2)];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Base fees
 | 
				
			||||||
 | 
					      query = `SELECT node1_base_fee_mtokens FROM channels WHERE node1_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
 | 
				
			||||||
 | 
					      let [baseFees1]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      baseFees1 = baseFees1.map(rate => rate.node1_base_fee_mtokens);
 | 
				
			||||||
 | 
					      query = `SELECT node2_base_fee_mtokens FROM channels WHERE node2_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
 | 
				
			||||||
 | 
					      let [baseFees2]: any = await DB.query(query);
 | 
				
			||||||
 | 
					      baseFees2 = baseFees2.map(rate => rate.node2_base_fee_mtokens);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let baseFees = (baseFees1.concat(baseFees2)).sort((a, b) => a - b);
 | 
				
			||||||
 | 
					      let avgBaseFee = 0;
 | 
				
			||||||
 | 
					      for (const fee of baseFees) {
 | 
				
			||||||
 | 
					        avgBaseFee += fee; 
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      avgBaseFee /= baseFees.length;
 | 
				
			||||||
 | 
					      const medianBaseFee = feeRates[Math.floor(baseFees.length / 2)];
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        avgCapacity: parseInt(avgCapacity[0].avgCapacity, 10),
 | 
				
			||||||
 | 
					        avgFeeRate: avgFeeRate,
 | 
				
			||||||
 | 
					        avgBaseFee: avgBaseFee,
 | 
				
			||||||
 | 
					        medianCapacity: medianCapacity,
 | 
				
			||||||
 | 
					        medianFeeRate: medianFeeRate,
 | 
				
			||||||
 | 
					        medianBaseFee: medianBaseFee,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot calculate channels statistics. Reason: ${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();
 | 
				
			||||||
							
								
								
									
										98
									
								
								backend/src/api/explorer/channels.routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								backend/src/api/explorer/channels.routes.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
							
								
								
									
										58
									
								
								backend/src/api/explorer/general.routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								backend/src/api/explorer/general.routes.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					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/:interval', 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(req.params.interval);
 | 
				
			||||||
 | 
					      const statisticsCount = await statisticsApi.$getStatisticsCount();
 | 
				
			||||||
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
 | 
					      res.header('X-total-count', statisticsCount.toString());
 | 
				
			||||||
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
				
			||||||
 | 
					      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();
 | 
				
			||||||
							
								
								
									
										62
									
								
								backend/src/api/explorer/nodes.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								backend/src/api/explorer/nodes.api.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
							
								
								
									
										61
									
								
								backend/src/api/explorer/nodes.routes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/api/explorer/nodes.routes.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
							
								
								
									
										52
									
								
								backend/src/api/explorer/statistics.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								backend/src/api/explorer/statistics.api.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					import logger from '../../logger';
 | 
				
			||||||
 | 
					import DB from '../../database';
 | 
				
			||||||
 | 
					import { Common } from '../common';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StatisticsApi {
 | 
				
			||||||
 | 
					  public async $getStatistics(interval: string | null = null): Promise<any> {
 | 
				
			||||||
 | 
					    interval = Common.getSqlInterval(interval);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity, tor_nodes, clearnet_nodes, unannounced_nodes
 | 
				
			||||||
 | 
					      FROM lightning_stats`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (interval) {
 | 
				
			||||||
 | 
					      query += ` WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    query += ` ORDER BY id DESC`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      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 7`);
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        latest: rows[0],
 | 
				
			||||||
 | 
					        previous: rows2[0],
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getStatisticsCount(): Promise<number> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [rows]: any = await DB.query(`SELECT count(*) as count FROM lightning_stats`);
 | 
				
			||||||
 | 
					      return rows[0].count;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default new StatisticsApi();
 | 
				
			||||||
@ -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>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										13
									
								
								backend/src/api/lightning/lightning-api-factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/src/api/lightning/lightning-api-factory.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
							
								
								
									
										71
									
								
								backend/src/api/lightning/lightning-api.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								backend/src/api/lightning/lightning-api.interface.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										45
									
								
								backend/src/api/lightning/lnd/lnd-api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								backend/src/api/lightning/lnd/lnd-api.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
@ -173,8 +173,6 @@ class Mining {
 | 
				
			|||||||
   */
 | 
					   */
 | 
				
			||||||
  public async $generatePoolHashrateHistory(): Promise<void> {
 | 
					  public async $generatePoolHashrateHistory(): Promise<void> {
 | 
				
			||||||
    const now = new Date();
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
    const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing');
 | 
					    const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Run only if:
 | 
					    // Run only if:
 | 
				
			||||||
@ -184,14 +182,15 @@ class Mining {
 | 
				
			|||||||
    if (!runIndexing) {
 | 
					    if (!runIndexing) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    } catch (e) {
 | 
					 | 
				
			||||||
      throw e;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
					      const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
 | 
				
			||||||
 | 
					      const genesisTimestamp = genesisBlock.time * 1000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
 | 
					      const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
 | 
				
			||||||
      const hashrates: any[] = [];
 | 
					      const hashrates: any[] = [];
 | 
				
			||||||
      const genesisTimestamp = 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
 | 
					 | 
				
			||||||
 
 | 
					 
 | 
				
			||||||
      const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
 | 
					      const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
 | 
				
			||||||
      const lastMondayMidnight = this.getDateMidnight(lastMonday);
 | 
					      const lastMondayMidnight = this.getDateMidnight(lastMonday);
 | 
				
			||||||
@ -207,7 +206,7 @@ class Mining {
 | 
				
			|||||||
      logger.debug(`Indexing weekly mining pool hashrate`);
 | 
					      logger.debug(`Indexing weekly mining pool hashrate`);
 | 
				
			||||||
      loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
 | 
					      loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      while (toTimestamp > genesisTimestamp) {
 | 
					      while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
 | 
				
			||||||
        const fromTimestamp = toTimestamp - 604800000;
 | 
					        const fromTimestamp = toTimestamp - 604800000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Skip already indexed weeks
 | 
					        // Skip already indexed weeks
 | 
				
			||||||
@ -217,14 +216,6 @@ class Mining {
 | 
				
			|||||||
          continue;
 | 
					          continue;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Check if we have blocks for the previous week (which mean that the week
 | 
					 | 
				
			||||||
        // we are currently indexing has complete data)
 | 
					 | 
				
			||||||
        const blockStatsPreviousWeek: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
					 | 
				
			||||||
          null, (fromTimestamp - 604800000) / 1000, (toTimestamp - 604800000) / 1000);
 | 
					 | 
				
			||||||
        if (blockStatsPreviousWeek.blockCount === 0) { // We are done indexing
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
					        const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
				
			||||||
          null, fromTimestamp / 1000, toTimestamp / 1000);
 | 
					          null, fromTimestamp / 1000, toTimestamp / 1000);
 | 
				
			||||||
        const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
 | 
					        const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
 | 
				
			||||||
@ -232,6 +223,7 @@ class Mining {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
 | 
					        let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
 | 
				
			||||||
        const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
 | 
					        const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
 | 
				
			||||||
 | 
					        if (totalBlocks > 0) {
 | 
				
			||||||
          pools = pools.map((pool: any) => {
 | 
					          pools = pools.map((pool: any) => {
 | 
				
			||||||
            pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
 | 
					            pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
 | 
				
			||||||
            pool.share = (pool.blockCount / totalBlocks);
 | 
					            pool.share = (pool.blockCount / totalBlocks);
 | 
				
			||||||
@ -241,7 +233,7 @@ class Mining {
 | 
				
			|||||||
          for (const pool of pools) {
 | 
					          for (const pool of pools) {
 | 
				
			||||||
            hashrates.push({
 | 
					            hashrates.push({
 | 
				
			||||||
              hashrateTimestamp: toTimestamp / 1000,
 | 
					              hashrateTimestamp: toTimestamp / 1000,
 | 
				
			||||||
            avgHashrate: pool['hashrate'],
 | 
					              avgHashrate: pool['hashrate'] ,
 | 
				
			||||||
              poolId: pool.poolId,
 | 
					              poolId: pool.poolId,
 | 
				
			||||||
              share: pool['share'],
 | 
					              share: pool['share'],
 | 
				
			||||||
              type: 'weekly',
 | 
					              type: 'weekly',
 | 
				
			||||||
@ -251,6 +243,7 @@ class Mining {
 | 
				
			|||||||
          newlyIndexed += hashrates.length;
 | 
					          newlyIndexed += hashrates.length;
 | 
				
			||||||
          await HashratesRepository.$saveHashrates(hashrates);
 | 
					          await HashratesRepository.$saveHashrates(hashrates);
 | 
				
			||||||
          hashrates.length = 0;
 | 
					          hashrates.length = 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
 | 
					        const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
 | 
				
			||||||
        if (elapsedSeconds > 1) {
 | 
					        if (elapsedSeconds > 1) {
 | 
				
			||||||
@ -285,20 +278,19 @@ class Mining {
 | 
				
			|||||||
   * [INDEXING] Generate daily hashrate data
 | 
					   * [INDEXING] Generate daily hashrate data
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  public async $generateNetworkHashrateHistory(): Promise<void> {
 | 
					  public async $generateNetworkHashrateHistory(): Promise<void> {
 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
    // We only run this once a day around midnight
 | 
					    // We only run this once a day around midnight
 | 
				
			||||||
    const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing');
 | 
					    const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing');
 | 
				
			||||||
    const now = new Date().getUTCDate();
 | 
					    const now = new Date().getUTCDate();
 | 
				
			||||||
    if (now === latestRunDate) {
 | 
					    if (now === latestRunDate) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    } catch (e) {
 | 
					
 | 
				
			||||||
      throw e;
 | 
					    const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
 | 
					      const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
 | 
				
			||||||
      const genesisTimestamp = (config.MEMPOOL.NETWORK === 'signet') ? 1598918400000 : 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
 | 
					      const genesisTimestamp = genesisBlock.time * 1000;
 | 
				
			||||||
 | 
					      const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
 | 
				
			||||||
      const lastMidnight = this.getDateMidnight(new Date());
 | 
					      const lastMidnight = this.getDateMidnight(new Date());
 | 
				
			||||||
      let toTimestamp = Math.round(lastMidnight.getTime());
 | 
					      let toTimestamp = Math.round(lastMidnight.getTime());
 | 
				
			||||||
      const hashrates: any[] = [];
 | 
					      const hashrates: any[] = [];
 | 
				
			||||||
@ -313,7 +305,7 @@ class Mining {
 | 
				
			|||||||
      logger.debug(`Indexing daily network hashrate`);
 | 
					      logger.debug(`Indexing daily network hashrate`);
 | 
				
			||||||
      loadingIndicators.setProgress('daily-hashrate-indexing', 0);
 | 
					      loadingIndicators.setProgress('daily-hashrate-indexing', 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      while (toTimestamp > genesisTimestamp) {
 | 
					      while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
 | 
				
			||||||
        const fromTimestamp = toTimestamp - 86400000;
 | 
					        const fromTimestamp = toTimestamp - 86400000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Skip already indexed days
 | 
					        // Skip already indexed days
 | 
				
			||||||
@ -323,17 +315,9 @@ class Mining {
 | 
				
			|||||||
          continue;
 | 
					          continue;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Check if we have blocks for the previous day (which mean that the day
 | 
					 | 
				
			||||||
        // we are currently indexing has complete data)
 | 
					 | 
				
			||||||
        const blockStatsPreviousDay: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
					 | 
				
			||||||
          null, (fromTimestamp - 86400000) / 1000, (toTimestamp - 86400000) / 1000);
 | 
					 | 
				
			||||||
        if (blockStatsPreviousDay.blockCount === 0 && config.MEMPOOL.NETWORK === 'mainnet') { // We are done indexing
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
					        const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
 | 
				
			||||||
          null, fromTimestamp / 1000, toTimestamp / 1000);
 | 
					          null, fromTimestamp / 1000, toTimestamp / 1000);
 | 
				
			||||||
        const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
 | 
					        const lastBlockHashrate = blockStats.blockCount === 0 ? 0 : await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
 | 
				
			||||||
          blockStats.lastBlockHeight);
 | 
					          blockStats.lastBlockHeight);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        hashrates.push({
 | 
					        hashrates.push({
 | 
				
			||||||
@ -368,8 +352,8 @@ class Mining {
 | 
				
			|||||||
        ++totalIndexed;
 | 
					        ++totalIndexed;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Add genesis block manually on mainnet and testnet
 | 
					      // Add genesis block manually
 | 
				
			||||||
      if ('signet' !== config.MEMPOOL.NETWORK && toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) {
 | 
					      if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && !indexedTimestamp.includes(genesisTimestamp / 1000)) {
 | 
				
			||||||
        hashrates.push({
 | 
					        hashrates.push({
 | 
				
			||||||
          hashrateTimestamp: genesisTimestamp / 1000,
 | 
					          hashrateTimestamp: genesisTimestamp / 1000,
 | 
				
			||||||
          avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
 | 
					          avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
 | 
				
			||||||
@ -405,27 +389,37 @@ class Mining {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const blocks: any = await BlocksRepository.$getBlocksDifficulty();
 | 
					    const blocks: any = await BlocksRepository.$getBlocksDifficulty();
 | 
				
			||||||
 | 
					    const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0));
 | 
				
			||||||
    let currentDifficulty = 0;
 | 
					    let currentDifficulty = genesisBlock.difficulty;
 | 
				
			||||||
    let totalIndexed = 0;
 | 
					    let totalIndexed = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (indexedHeights[0] !== true) {
 | 
					    if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) {
 | 
				
			||||||
      await DifficultyAdjustmentsRepository.$saveAdjustments({
 | 
					      await DifficultyAdjustmentsRepository.$saveAdjustments({
 | 
				
			||||||
        time: (config.MEMPOOL.NETWORK === 'signet') ? 1598918400 : 1231006505,
 | 
					        time: genesisBlock.time,
 | 
				
			||||||
        height: 0,
 | 
					        height: 0,
 | 
				
			||||||
        difficulty: (config.MEMPOOL.NETWORK === 'signet') ? 0.001126515290698186 : 1.0,
 | 
					        difficulty: currentDifficulty,
 | 
				
			||||||
        adjustment: 0.0,
 | 
					        adjustment: 0.0,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock();
 | 
				
			||||||
 | 
					    if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) {
 | 
				
			||||||
 | 
					      currentDifficulty = oldestConsecutiveBlock.difficulty;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let totalBlockChecked = 0;
 | 
				
			||||||
 | 
					    let timer = new Date().getTime() / 1000;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const block of blocks) {
 | 
					    for (const block of blocks) {
 | 
				
			||||||
      if (block.difficulty !== currentDifficulty) {
 | 
					      if (block.difficulty !== currentDifficulty) {
 | 
				
			||||||
        if (block.height === 0 || indexedHeights[block.height] === true) { // Already indexed
 | 
					        if (indexedHeights[block.height] === true) { // Already indexed
 | 
				
			||||||
 | 
					          if (block.height >= oldestConsecutiveBlock.height) {
 | 
				
			||||||
            currentDifficulty = block.difficulty;
 | 
					            currentDifficulty = block.difficulty;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
          continue;          
 | 
					          continue;          
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        let adjustment = block.difficulty / Math.max(1, currentDifficulty);
 | 
					        let adjustment = block.difficulty / currentDifficulty;
 | 
				
			||||||
        adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
 | 
					        adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await DifficultyAdjustmentsRepository.$saveAdjustments({
 | 
					        await DifficultyAdjustmentsRepository.$saveAdjustments({
 | 
				
			||||||
@ -436,10 +430,20 @@ class Mining {
 | 
				
			|||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        totalIndexed++;
 | 
					        totalIndexed++;
 | 
				
			||||||
 | 
					        if (block.height >= oldestConsecutiveBlock.height) {
 | 
				
			||||||
          currentDifficulty = block.difficulty;
 | 
					          currentDifficulty = block.difficulty;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      totalBlockChecked++;
 | 
				
			||||||
 | 
					      const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
 | 
				
			||||||
 | 
					      if (elapsedSeconds > 5) {
 | 
				
			||||||
 | 
					        const progress = Math.round(totalBlockChecked / blocks.length * 100);
 | 
				
			||||||
 | 
					        logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`);
 | 
				
			||||||
 | 
					        timer = new Date().getTime() / 1000;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (totalIndexed > 0) {
 | 
					    if (totalIndexed > 0) {
 | 
				
			||||||
      logger.notice(`Indexed ${totalIndexed} difficulty adjustments`);
 | 
					      logger.notice(`Indexed ${totalIndexed} difficulty adjustments`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -222,6 +222,10 @@ class PoolsParser {
 | 
				
			|||||||
   * Delete blocks which needs to be reindexed
 | 
					   * Delete blocks which needs to be reindexed
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
   private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) {
 | 
					   private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) {
 | 
				
			||||||
 | 
					    if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const blockCount = await BlocksRepository.$blockCount(null, null);
 | 
					    const blockCount = await BlocksRepository.$blockCount(null, null);
 | 
				
			||||||
    if (blockCount === 0) {
 | 
					    if (blockCount === 0) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
 | 
				
			|||||||
@ -23,10 +23,20 @@ interface IConfig {
 | 
				
			|||||||
    EXTERNAL_RETRY_INTERVAL: number;
 | 
					    EXTERNAL_RETRY_INTERVAL: number;
 | 
				
			||||||
    USER_AGENT: string;
 | 
					    USER_AGENT: string;
 | 
				
			||||||
    STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
 | 
					    STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
 | 
				
			||||||
 | 
					    AUTOMATIC_BLOCK_REINDEXING: boolean;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  ESPLORA: {
 | 
					  ESPLORA: {
 | 
				
			||||||
    REST_API_URL: string;
 | 
					    REST_API_URL: string;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					  LIGHTNING: {
 | 
				
			||||||
 | 
					    ENABLED: boolean;
 | 
				
			||||||
 | 
					    BACKEND: 'lnd' | 'cln' | 'ldk';
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  LND: {
 | 
				
			||||||
 | 
					    TLS_CERT_PATH: string;
 | 
				
			||||||
 | 
					    MACAROON_PATH: string;
 | 
				
			||||||
 | 
					    SOCKET: string;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
  ELECTRUM: {
 | 
					  ELECTRUM: {
 | 
				
			||||||
    HOST: string;
 | 
					    HOST: string;
 | 
				
			||||||
    PORT: number;
 | 
					    PORT: number;
 | 
				
			||||||
@ -113,6 +123,7 @@ const defaults: IConfig = {
 | 
				
			|||||||
    'EXTERNAL_RETRY_INTERVAL': 0,
 | 
					    'EXTERNAL_RETRY_INTERVAL': 0,
 | 
				
			||||||
    'USER_AGENT': 'mempool',
 | 
					    'USER_AGENT': 'mempool',
 | 
				
			||||||
    'STDOUT_LOG_MIN_PRIORITY': 'debug',
 | 
					    'STDOUT_LOG_MIN_PRIORITY': 'debug',
 | 
				
			||||||
 | 
					    'AUTOMATIC_BLOCK_REINDEXING': false,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  'ESPLORA': {
 | 
					  'ESPLORA': {
 | 
				
			||||||
    'REST_API_URL': 'http://127.0.0.1:3000',
 | 
					    'REST_API_URL': 'http://127.0.0.1:3000',
 | 
				
			||||||
@ -158,6 +169,15 @@ const defaults: IConfig = {
 | 
				
			|||||||
    'ENABLED': false,
 | 
					    'ENABLED': false,
 | 
				
			||||||
    'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
 | 
					    'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					  'LIGHTNING': {
 | 
				
			||||||
 | 
					    'ENABLED': false,
 | 
				
			||||||
 | 
					    'BACKEND': 'lnd'
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  'LND': {
 | 
				
			||||||
 | 
					    'TLS_CERT_PATH': '',
 | 
				
			||||||
 | 
					    'MACAROON_PATH': '',
 | 
				
			||||||
 | 
					    'SOCKET': 'localhost:10009',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  'SOCKS5PROXY': {
 | 
					  'SOCKS5PROXY': {
 | 
				
			||||||
    'ENABLED': false,
 | 
					    'ENABLED': false,
 | 
				
			||||||
    'USE_ONION': true,
 | 
					    'USE_ONION': true,
 | 
				
			||||||
@ -166,11 +186,11 @@ const defaults: IConfig = {
 | 
				
			|||||||
    'USERNAME': '',
 | 
					    'USERNAME': '',
 | 
				
			||||||
    'PASSWORD': ''
 | 
					    'PASSWORD': ''
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "PRICE_DATA_SERVER": {
 | 
					  'PRICE_DATA_SERVER': {
 | 
				
			||||||
    'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
 | 
					    'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
 | 
				
			||||||
    'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
 | 
					    'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "EXTERNAL_DATA_SERVER": {
 | 
					  'EXTERNAL_DATA_SERVER': {
 | 
				
			||||||
    'MEMPOOL_API': 'https://mempool.space/api/v1',
 | 
					    'MEMPOOL_API': 'https://mempool.space/api/v1',
 | 
				
			||||||
    'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
 | 
					    'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
 | 
				
			||||||
    'LIQUID_API': 'https://liquid.network/api/v1',
 | 
					    'LIQUID_API': 'https://liquid.network/api/v1',
 | 
				
			||||||
@ -190,6 +210,8 @@ class Config implements IConfig {
 | 
				
			|||||||
  SYSLOG: IConfig['SYSLOG'];
 | 
					  SYSLOG: IConfig['SYSLOG'];
 | 
				
			||||||
  STATISTICS: IConfig['STATISTICS'];
 | 
					  STATISTICS: IConfig['STATISTICS'];
 | 
				
			||||||
  BISQ: IConfig['BISQ'];
 | 
					  BISQ: IConfig['BISQ'];
 | 
				
			||||||
 | 
					  LIGHTNING: IConfig['LIGHTNING'];
 | 
				
			||||||
 | 
					  LND: IConfig['LND'];
 | 
				
			||||||
  SOCKS5PROXY: IConfig['SOCKS5PROXY'];
 | 
					  SOCKS5PROXY: IConfig['SOCKS5PROXY'];
 | 
				
			||||||
  PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
 | 
					  PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
 | 
				
			||||||
  EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
 | 
					  EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
 | 
				
			||||||
@ -205,6 +227,8 @@ class Config implements IConfig {
 | 
				
			|||||||
    this.SYSLOG = configs.SYSLOG;
 | 
					    this.SYSLOG = configs.SYSLOG;
 | 
				
			||||||
    this.STATISTICS = configs.STATISTICS;
 | 
					    this.STATISTICS = configs.STATISTICS;
 | 
				
			||||||
    this.BISQ = configs.BISQ;
 | 
					    this.BISQ = configs.BISQ;
 | 
				
			||||||
 | 
					    this.LIGHTNING = configs.LIGHTNING;
 | 
				
			||||||
 | 
					    this.LND = configs.LND;
 | 
				
			||||||
    this.SOCKS5PROXY = configs.SOCKS5PROXY;
 | 
					    this.SOCKS5PROXY = configs.SOCKS5PROXY;
 | 
				
			||||||
    this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
 | 
					    this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
 | 
				
			||||||
    this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
 | 
					    this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
import express from "express";
 | 
					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 http from 'http';
 | 
				
			||||||
import * as WebSocket from 'ws';
 | 
					import * as WebSocket from 'ws';
 | 
				
			||||||
import cluster from 'cluster';
 | 
					import cluster from 'cluster';
 | 
				
			||||||
@ -28,6 +28,11 @@ import { Common } from './api/common';
 | 
				
			|||||||
import poolsUpdater from './tasks/pools-updater';
 | 
					import poolsUpdater from './tasks/pools-updater';
 | 
				
			||||||
import indexer from './indexer';
 | 
					import indexer from './indexer';
 | 
				
			||||||
import priceUpdater from './tasks/price-updater';
 | 
					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';
 | 
					import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Server {
 | 
					class Server {
 | 
				
			||||||
@ -130,6 +135,11 @@ class Server {
 | 
				
			|||||||
      bisqMarkets.startBisqService();
 | 
					      bisqMarkets.startBisqService();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (config.LIGHTNING.ENABLED) {
 | 
				
			||||||
 | 
					      nodeSyncService.$startService()
 | 
				
			||||||
 | 
					        .then(() => lightningStatsUpdater.$startService());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
 | 
					    this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
 | 
				
			||||||
      if (worker) {
 | 
					      if (worker) {
 | 
				
			||||||
        logger.info(`Mempool Server worker #${process.pid} started`);
 | 
					        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)
 | 
					        .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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -35,6 +35,8 @@ class Indexer {
 | 
				
			|||||||
    this.runIndexer = false;
 | 
					    this.runIndexer = false;
 | 
				
			||||||
    this.indexerRunning = true;
 | 
					    this.indexerRunning = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.debug(`Running mining indexer`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const chainValid = await blocks.$generateBlockDatabase();
 | 
					      const chainValid = await blocks.$generateBlockDatabase();
 | 
				
			||||||
      if (chainValid === false) {
 | 
					      if (chainValid === false) {
 | 
				
			||||||
@ -54,9 +56,15 @@ class Indexer {
 | 
				
			|||||||
      this.indexerRunning = false;
 | 
					      this.indexerRunning = false;
 | 
				
			||||||
      logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
 | 
					      logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
 | 
				
			||||||
      setTimeout(() => this.reindex(), 10000);
 | 
					      setTimeout(() => this.reindex(), 10000);
 | 
				
			||||||
 | 
					      this.indexerRunning = false;
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.indexerRunning = false;
 | 
					    this.indexerRunning = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const runEvery = 1000 * 3600; // 1 hour
 | 
				
			||||||
 | 
					    logger.debug(`Indexing completed. Next run planned at ${new Date(new Date().getTime() + runEvery).toUTCString()}`);
 | 
				
			||||||
 | 
					    setTimeout(() => this.reindex(), runEvery);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async $resetHashratesIndexingState() {
 | 
					  async $resetHashratesIndexingState() {
 | 
				
			||||||
 | 
				
			|||||||
@ -610,6 +610,24 @@ class BlocksRepository {
 | 
				
			|||||||
      throw e;
 | 
					      throw e;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Return the oldest block  from a consecutive chain of block from the most recent one
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  public async $getOldestConsecutiveBlock(): Promise<any> {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty FROM blocks ORDER BY height DESC`);
 | 
				
			||||||
 | 
					      for (let i = 0; i < rows.length - 1; ++i) {
 | 
				
			||||||
 | 
					        if (rows[i].height - rows[i + 1].height > 1) {
 | 
				
			||||||
 | 
					          return rows[i];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return rows[rows.length - 1];
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default new BlocksRepository();
 | 
					export default new BlocksRepository();
 | 
				
			||||||
 | 
				
			|||||||
@ -46,9 +46,38 @@ class DifficultyAdjustmentsRepository {
 | 
				
			|||||||
    query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`;
 | 
					    query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (descOrder === true) {
 | 
					    if (descOrder === true) {
 | 
				
			||||||
      query += ` ORDER BY time DESC`;
 | 
					      query += ` ORDER BY height DESC`;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      query += ` ORDER BY time`;
 | 
					      query += ` ORDER BY height`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [rows] = await DB.query(query);
 | 
				
			||||||
 | 
					      return rows as IndexedDifficultyAdjustment[];
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getRawAdjustments(interval: string | null, descOrder: boolean = false): Promise<IndexedDifficultyAdjustment[]> {
 | 
				
			||||||
 | 
					    interval = Common.getSqlInterval(interval);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let query = `SELECT 
 | 
				
			||||||
 | 
					      UNIX_TIMESTAMP(time) as time,
 | 
				
			||||||
 | 
					      height as height,
 | 
				
			||||||
 | 
					      difficulty as difficulty,
 | 
				
			||||||
 | 
					      adjustment as adjustment
 | 
				
			||||||
 | 
					      FROM difficulty_adjustments`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (interval) {
 | 
				
			||||||
 | 
					      query += ` WHERE time BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (descOrder === true) {
 | 
				
			||||||
 | 
					      query += ` ORDER BY height DESC`;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      query += ` ORDER BY height`;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
import { escape } from 'mysql2';
 | 
					import { escape } from 'mysql2';
 | 
				
			||||||
import { Common } from '../api/common';
 | 
					import { Common } from '../api/common';
 | 
				
			||||||
import config from '../config';
 | 
					 | 
				
			||||||
import DB from '../database';
 | 
					import DB from '../database';
 | 
				
			||||||
import logger from '../logger';
 | 
					import logger from '../logger';
 | 
				
			||||||
import PoolsRepository from './PoolsRepository';
 | 
					import PoolsRepository from './PoolsRepository';
 | 
				
			||||||
@ -30,6 +29,32 @@ class HashratesRepository {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public async $getRawNetworkDailyHashrate(interval: string | null): Promise<any[]> {
 | 
				
			||||||
 | 
					    interval = Common.getSqlInterval(interval);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let query = `SELECT
 | 
				
			||||||
 | 
					      UNIX_TIMESTAMP(hashrate_timestamp) as timestamp,
 | 
				
			||||||
 | 
					      avg_hashrate as avgHashrate
 | 
				
			||||||
 | 
					      FROM hashrates`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (interval) {
 | 
				
			||||||
 | 
					      query += ` WHERE hashrate_timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()
 | 
				
			||||||
 | 
					        AND hashrates.type = 'daily'`;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      query += ` WHERE hashrates.type = 'daily'`;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    query += ` ORDER by hashrate_timestamp`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [rows]: any[] = await DB.query(query);
 | 
				
			||||||
 | 
					      return rows;
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					      throw e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
 | 
					  public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
 | 
				
			||||||
    interval = Common.getSqlInterval(interval);
 | 
					    interval = Common.getSqlInterval(interval);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,12 @@ import { Prices } from '../tasks/price-updater';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class PricesRepository {
 | 
					class PricesRepository {
 | 
				
			||||||
  public async $savePrices(time: number, prices: Prices): Promise<void> {
 | 
					  public async $savePrices(time: number, prices: Prices): Promise<void> {
 | 
				
			||||||
 | 
					    if (prices.USD === -1) {
 | 
				
			||||||
 | 
					      // Some historical price entries have not USD prices, so we just ignore them to avoid future UX issues
 | 
				
			||||||
 | 
					      // As of today there are only 4 (on 2013-09-05, 2013-09-19, 2013-09-12 and 2013-09-26) so that's fine
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await DB.query(`
 | 
					      await DB.query(`
 | 
				
			||||||
        INSERT INTO prices(time,             USD, EUR, GBP, CAD, CHF, AUD, JPY)
 | 
					        INSERT INTO prices(time,             USD, EUR, GBP, CAD, CHF, AUD, JPY)
 | 
				
			||||||
@ -17,17 +23,17 @@ class PricesRepository {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $getOldestPriceTime(): Promise<number> {
 | 
					  public async $getOldestPriceTime(): Promise<number> {
 | 
				
			||||||
    const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices ORDER BY time LIMIT 1`);
 | 
					    const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1 ORDER BY time LIMIT 1`);
 | 
				
			||||||
    return oldestRow[0] ? oldestRow[0].time : 0;
 | 
					    return oldestRow[0] ? oldestRow[0].time : 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $getLatestPriceTime(): Promise<number> {
 | 
					  public async $getLatestPriceTime(): Promise<number> {
 | 
				
			||||||
    const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices ORDER BY time DESC LIMIT 1`);
 | 
					    const [oldestRow] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1 ORDER BY time DESC LIMIT 1`);
 | 
				
			||||||
    return oldestRow[0] ? oldestRow[0].time : 0;
 | 
					    return oldestRow[0] ? oldestRow[0].time : 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public async $getPricesTimes(): Promise<number[]> {
 | 
					  public async $getPricesTimes(): Promise<number[]> {
 | 
				
			||||||
    const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices`);
 | 
					    const [times]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(time) as time from prices WHERE USD != -1`);
 | 
				
			||||||
    return times.map(time => time.time);
 | 
					    return times.map(time => time.time);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -734,7 +734,7 @@ class Routes {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  public async $getDifficultyAdjustments(req: Request, res: Response) {
 | 
					  public async $getDifficultyAdjustments(req: Request, res: Response) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const difficulty = await DifficultyAdjustmentsRepository.$getAdjustments(req.params.interval, true);
 | 
					      const difficulty = await DifficultyAdjustmentsRepository.$getRawAdjustments(req.params.interval, true);
 | 
				
			||||||
      res.header('Pragma', 'public');
 | 
					      res.header('Pragma', 'public');
 | 
				
			||||||
      res.header('Cache-control', 'public');
 | 
					      res.header('Cache-control', 'public');
 | 
				
			||||||
      res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
 | 
					      res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
 | 
				
			||||||
@ -790,7 +790,7 @@ class Routes {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  public async getBlocks(req: Request, res: Response) {
 | 
					  public async getBlocks(req: Request, res: Response) {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
 | 
					      if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
 | 
				
			||||||
        const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
 | 
					        const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
 | 
				
			||||||
        res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
					        res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
 | 
				
			||||||
        res.json(await blocks.$getBlocks(height, 15));
 | 
					        res.json(await blocks.$getBlocks(height, 15));
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										396
									
								
								backend/src/tasks/lightning/node-sync.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										396
									
								
								backend/src/tasks/lightning/node-sync.service.ts
									
									
									
									
									
										Normal 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();
 | 
				
			||||||
							
								
								
									
										276
									
								
								backend/src/tasks/lightning/stats-updater.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										276
									
								
								backend/src/tasks/lightning/stats-updater.service.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,276 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					import DB from '../../database';
 | 
				
			||||||
 | 
					import logger from '../../logger';
 | 
				
			||||||
 | 
					import lightningApi from '../../api/lightning/lightning-api-factory';
 | 
				
			||||||
 | 
					import channelsApi from '../../api/explorer/channels.api';
 | 
				
			||||||
 | 
					import * as net from 'net';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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,
 | 
				
			||||||
 | 
					          tor_nodes,
 | 
				
			||||||
 | 
					          clearnet_nodes,
 | 
				
			||||||
 | 
					          unannounced_nodes
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        VALUES (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await DB.query(query, [
 | 
				
			||||||
 | 
					          date.getTime() / 1000,
 | 
				
			||||||
 | 
					          channelsCount,
 | 
				
			||||||
 | 
					          0,
 | 
				
			||||||
 | 
					          totalCapacity,
 | 
				
			||||||
 | 
					          0,
 | 
				
			||||||
 | 
					          0,
 | 
				
			||||||
 | 
					          0
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add one day and continue
 | 
				
			||||||
 | 
					        date.setDate(date.getDate() + 1);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const [nodes]: any = await DB.query(`SELECT first_seen, sockets FROM nodes ORDER BY first_seen ASC`);
 | 
				
			||||||
 | 
					      date = new Date(startTime);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      while (date < currentDate) {
 | 
				
			||||||
 | 
					        let nodeCount = 0;
 | 
				
			||||||
 | 
					        let clearnetNodes = 0;
 | 
				
			||||||
 | 
					        let torNodes = 0;
 | 
				
			||||||
 | 
					        let unannouncedNodes = 0;
 | 
				
			||||||
 | 
					        for (const node of nodes) {
 | 
				
			||||||
 | 
					          if (new Date(node.first_seen) > date) {
 | 
				
			||||||
 | 
					            break;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          nodeCount++;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          const sockets = node.sockets.split(',');
 | 
				
			||||||
 | 
					          let isUnnanounced = true;
 | 
				
			||||||
 | 
					          for (const socket of sockets) {
 | 
				
			||||||
 | 
					            const hasOnion = socket.indexOf('.onion') !== -1;
 | 
				
			||||||
 | 
					            if (hasOnion) {
 | 
				
			||||||
 | 
					              torNodes++;
 | 
				
			||||||
 | 
					              isUnnanounced = false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const hasClearnet = [4, 6].includes(net.isIP(socket.split(':')[0]));
 | 
				
			||||||
 | 
					            if (hasClearnet) {
 | 
				
			||||||
 | 
					              clearnetNodes++;
 | 
				
			||||||
 | 
					              isUnnanounced = false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          if (isUnnanounced) {
 | 
				
			||||||
 | 
					            unannouncedNodes++;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const query = `UPDATE lightning_stats SET node_count = ?, tor_nodes = ?, clearnet_nodes = ?, unannounced_nodes = ? WHERE added = FROM_UNIXTIME(?)`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await DB.query(query, [
 | 
				
			||||||
 | 
					          nodeCount,
 | 
				
			||||||
 | 
					          torNodes,
 | 
				
			||||||
 | 
					          clearnetNodes,
 | 
				
			||||||
 | 
					          unannouncedNodes,
 | 
				
			||||||
 | 
					          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;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let clearnetNodes = 0;
 | 
				
			||||||
 | 
					      let torNodes = 0;
 | 
				
			||||||
 | 
					      let unannouncedNodes = 0;
 | 
				
			||||||
 | 
					      for (const node of networkGraph.nodes) {
 | 
				
			||||||
 | 
					        let isUnnanounced = true;
 | 
				
			||||||
 | 
					        for (const socket of node.sockets) {
 | 
				
			||||||
 | 
					          const hasOnion = socket.indexOf('.onion') !== -1;
 | 
				
			||||||
 | 
					          if (hasOnion) {
 | 
				
			||||||
 | 
					            torNodes++;
 | 
				
			||||||
 | 
					            isUnnanounced = false;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          const hasClearnet = [4, 6].includes(net.isIP(socket.split(':')[0]));
 | 
				
			||||||
 | 
					          if (hasClearnet) {
 | 
				
			||||||
 | 
					            clearnetNodes++;
 | 
				
			||||||
 | 
					            isUnnanounced = false;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (isUnnanounced) {
 | 
				
			||||||
 | 
					          unannouncedNodes++;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const channelStats = await channelsApi.$getChannelsStats();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const query = `INSERT INTO lightning_stats(
 | 
				
			||||||
 | 
					          added,
 | 
				
			||||||
 | 
					          channel_count,
 | 
				
			||||||
 | 
					          node_count,
 | 
				
			||||||
 | 
					          total_capacity,
 | 
				
			||||||
 | 
					          tor_nodes,
 | 
				
			||||||
 | 
					          clearnet_nodes,
 | 
				
			||||||
 | 
					          unannounced_nodes,
 | 
				
			||||||
 | 
					          avg_capacity,
 | 
				
			||||||
 | 
					          avg_fee_rate,
 | 
				
			||||||
 | 
					          avg_base_fee_mtokens,
 | 
				
			||||||
 | 
					          med_capacity,
 | 
				
			||||||
 | 
					          med_fee_rate,
 | 
				
			||||||
 | 
					          med_base_fee_mtokens
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        VALUES (NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await DB.query(query, [
 | 
				
			||||||
 | 
					        networkGraph.channels.length,
 | 
				
			||||||
 | 
					        networkGraph.nodes.length,
 | 
				
			||||||
 | 
					        total_capacity,
 | 
				
			||||||
 | 
					        torNodes,
 | 
				
			||||||
 | 
					        clearnetNodes,
 | 
				
			||||||
 | 
					        unannouncedNodes,
 | 
				
			||||||
 | 
					        channelStats.avgCapacity,
 | 
				
			||||||
 | 
					        channelStats.avgFeeRate,
 | 
				
			||||||
 | 
					        channelStats.avgBaseFee,
 | 
				
			||||||
 | 
					        channelStats.medianCapacity,
 | 
				
			||||||
 | 
					        channelStats.medianFeeRate,
 | 
				
			||||||
 | 
					        channelStats.medianBaseFee,
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					      logger.info(`Lightning daily stats done.`);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      logger.err('$logLightningStatsDaily() error: ' + (e instanceof Error ? e.message : e));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default new LightningStatsUpdater();
 | 
				
			||||||
@ -20,7 +20,8 @@
 | 
				
			|||||||
    "USER_AGENT": "__MEMPOOL_USER_AGENT__",
 | 
					    "USER_AGENT": "__MEMPOOL_USER_AGENT__",
 | 
				
			||||||
    "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
 | 
					    "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
 | 
				
			||||||
    "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
 | 
					    "INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
 | 
				
			||||||
    "BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__
 | 
					    "BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__,
 | 
				
			||||||
 | 
					    "AUTOMATIC_BLOCK_REINDEXING": __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "CORE_RPC": {
 | 
					  "CORE_RPC": {
 | 
				
			||||||
    "HOST": "__CORE_RPC_HOST__",
 | 
					    "HOST": "__CORE_RPC_HOST__",
 | 
				
			||||||
 | 
				
			|||||||
@ -22,6 +22,8 @@ __MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
 | 
				
			|||||||
__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
 | 
					__MEMPOOL_EXTERNAL_RETRY_INTERVAL__=${MEMPOOL_EXTERNAL_RETRY_INTERVAL:=0}
 | 
				
			||||||
__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
 | 
					__MEMPOOL_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool}
 | 
				
			||||||
__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
 | 
					__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
 | 
				
			||||||
 | 
					__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=false}
 | 
				
			||||||
 | 
					__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__=${MEMPOOL_AUTOMATIC_BLOCK_REINDEXING:=false}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# CORE_RPC
 | 
					# CORE_RPC
 | 
				
			||||||
__CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1}
 | 
					__CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1}
 | 
				
			||||||
@ -110,6 +112,8 @@ sed -i "s!__MEMPOOL_EXTERNAL_MAX_RETRY__!${__MEMPOOL_EXTERNAL_MAX_RETRY__}!g" me
 | 
				
			|||||||
sed -i "s!__MEMPOOL_EXTERNAL_RETRY_INTERVAL__!${__MEMPOOL_EXTERNAL_RETRY_INTERVAL__}!g" mempool-config.json
 | 
					sed -i "s!__MEMPOOL_EXTERNAL_RETRY_INTERVAL__!${__MEMPOOL_EXTERNAL_RETRY_INTERVAL__}!g" mempool-config.json
 | 
				
			||||||
sed -i "s!__MEMPOOL_USER_AGENT__!${__MEMPOOL_USER_AGENT__}!g" mempool-config.json
 | 
					sed -i "s!__MEMPOOL_USER_AGENT__!${__MEMPOOL_USER_AGENT__}!g" mempool-config.json
 | 
				
			||||||
sed -i "s/__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__/${__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__}/g" mempool-config.json
 | 
					sed -i "s/__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__/${__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__}/g" mempool-config.json
 | 
				
			||||||
 | 
					sed -i "s/__MEMPOOL_INDEXING_BLOCKS_AMOUNT__/${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}/g" mempool-config.json
 | 
				
			||||||
 | 
					sed -i "s/__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__/${__MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__}/g" mempool-config.json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json
 | 
					sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json
 | 
				
			||||||
sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json
 | 
					sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json
 | 
				
			||||||
 | 
				
			|||||||
@ -121,20 +121,20 @@ describe('Mainnet', () => {
 | 
				
			|||||||
        cy.visit('/');
 | 
					        cy.visit('/');
 | 
				
			||||||
        cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
 | 
					        cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
 | 
				
			||||||
          cy.wait('@search-1wiz');
 | 
					          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.get('.search-box-container > .form-control').type('S').then(() => {
 | 
				
			||||||
          cy.wait('@search-1wizS');
 | 
					          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.get('.search-box-container > .form-control').type('A').then(() => {
 | 
				
			||||||
          cy.wait('@search-1wizSA');
 | 
					          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.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC');
 | 
				
			||||||
          cy.waitForSkeletonGone();
 | 
					          cy.waitForSkeletonGone();
 | 
				
			||||||
          cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
 | 
					          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}`, () => {
 | 
					        it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => {
 | 
				
			||||||
          cy.visit('/');
 | 
					          cy.visit('/');
 | 
				
			||||||
          cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
 | 
					          cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
 | 
				
			||||||
            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/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
 | 
					              cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
 | 
				
			||||||
              cy.waitForSkeletonGone();
 | 
					              cy.waitForSkeletonGone();
 | 
				
			||||||
              cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
 | 
					              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}`, () => {
 | 
					        it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => {
 | 
				
			||||||
          cy.visit('/');
 | 
					          cy.visit('/');
 | 
				
			||||||
          cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
 | 
					          cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
 | 
				
			||||||
            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/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
 | 
					              cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
 | 
				
			||||||
              cy.waitForSkeletonGone();
 | 
					              cy.waitForSkeletonGone();
 | 
				
			||||||
              cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
 | 
					              cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
 | 
				
			||||||
 | 
				
			|||||||
@ -16,5 +16,6 @@
 | 
				
			|||||||
  "MEMPOOL_WEBSITE_URL": "https://mempool.space",
 | 
					  "MEMPOOL_WEBSITE_URL": "https://mempool.space",
 | 
				
			||||||
  "LIQUID_WEBSITE_URL": "https://liquid.network",
 | 
					  "LIQUID_WEBSITE_URL": "https://liquid.network",
 | 
				
			||||||
  "BISQ_WEBSITE_URL": "https://bisq.markets",
 | 
					  "BISQ_WEBSITE_URL": "https://bisq.markets",
 | 
				
			||||||
  "MINING_DASHBOARD": true
 | 
					  "MINING_DASHBOARD": true,
 | 
				
			||||||
 | 
					  "LIGHTNING": false
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -1,12 +1,12 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "mempool-frontend",
 | 
					  "name": "mempool-frontend",
 | 
				
			||||||
  "version": "2.4.1-dev",
 | 
					  "version": "2.5.0-dev",
 | 
				
			||||||
  "lockfileVersion": 2,
 | 
					  "lockfileVersion": 2,
 | 
				
			||||||
  "requires": true,
 | 
					  "requires": true,
 | 
				
			||||||
  "packages": {
 | 
					  "packages": {
 | 
				
			||||||
    "": {
 | 
					    "": {
 | 
				
			||||||
      "name": "mempool-frontend",
 | 
					      "name": "mempool-frontend",
 | 
				
			||||||
      "version": "2.4.1-dev",
 | 
					      "version": "2.5.0-dev",
 | 
				
			||||||
      "license": "GNU Affero General Public License v3.0",
 | 
					      "license": "GNU Affero General Public License v3.0",
 | 
				
			||||||
      "dependencies": {
 | 
					      "dependencies": {
 | 
				
			||||||
        "@angular-devkit/build-angular": "~13.3.7",
 | 
					        "@angular-devkit/build-angular": "~13.3.7",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "mempool-frontend",
 | 
					  "name": "mempool-frontend",
 | 
				
			||||||
  "version": "2.4.1-dev",
 | 
					  "version": "2.5.0-dev",
 | 
				
			||||||
  "description": "Bitcoin mempool visualizer and blockchain explorer backend",
 | 
					  "description": "Bitcoin mempool visualizer and blockchain explorer backend",
 | 
				
			||||||
  "license": "GNU Affero General Public License v3.0",
 | 
					  "license": "GNU Affero General Public License v3.0",
 | 
				
			||||||
  "homepage": "https://mempool.space",
 | 
					  "homepage": "https://mempool.space",
 | 
				
			||||||
 | 
				
			|||||||
@ -102,6 +102,16 @@ if (configContent && configContent.BASE_MODULE === 'bisq') {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PROXY_CONFIG.push(...[
 | 
					PROXY_CONFIG.push(...[
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    context: ['/testnet/api/v1/lightning/**'],
 | 
				
			||||||
 | 
					    target: `http://localhost:8999`,
 | 
				
			||||||
 | 
					    secure: false,
 | 
				
			||||||
 | 
					    changeOrigin: true,
 | 
				
			||||||
 | 
					    proxyTimeout: 30000,
 | 
				
			||||||
 | 
					    pathRewrite: {
 | 
				
			||||||
 | 
					        "^/testnet": ""
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    context: ['/api/v1/**'],
 | 
					    context: ['/api/v1/**'],
 | 
				
			||||||
    target: `http://localhost:8999`,
 | 
					    target: `http://localhost:8999`,
 | 
				
			||||||
 | 
				
			|||||||
@ -96,6 +96,10 @@ let routes: Routes = [
 | 
				
			|||||||
            path: 'api',
 | 
					            path: 'api',
 | 
				
			||||||
            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					            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',
 | 
					            path: 'api',
 | 
				
			||||||
            loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					            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',
 | 
					        path: 'api',
 | 
				
			||||||
        loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
					        loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        path: 'lightning',
 | 
				
			||||||
 | 
					        loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,13 @@
 | 
				
			|||||||
<span
 | 
					<a *ngIf="channel; else default" [routerLink]="['/lightning/channel' | relativeUrl, channel.id]">
 | 
				
			||||||
 | 
					  <span
 | 
				
			||||||
    *ngIf="label"
 | 
					    *ngIf="label"
 | 
				
			||||||
    class="badge badge-pill badge-warning"
 | 
					    class="badge badge-pill badge-warning"
 | 
				
			||||||
>{{ label }}</span>
 | 
					  >{{ label }}</span>
 | 
				
			||||||
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<ng-template #default>
 | 
				
			||||||
 | 
					  <span
 | 
				
			||||||
 | 
					    *ngIf="label"
 | 
				
			||||||
 | 
					    class="badge badge-pill badge-warning"
 | 
				
			||||||
 | 
					  >{{ label }}</span>
 | 
				
			||||||
 | 
					</ng-template>
 | 
				
			||||||
@ -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 { Vin, Vout } from '../../interfaces/electrs.interface';
 | 
				
			||||||
import { StateService } from 'src/app/services/state.service';
 | 
					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'],
 | 
					  styleUrls: ['./address-labels.component.scss'],
 | 
				
			||||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class AddressLabelsComponent implements OnInit {
 | 
					export class AddressLabelsComponent implements OnChanges {
 | 
				
			||||||
  network = '';
 | 
					  network = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Input() vin: Vin;
 | 
					  @Input() vin: Vin;
 | 
				
			||||||
  @Input() vout: Vout;
 | 
					  @Input() vout: Vout;
 | 
				
			||||||
 | 
					  @Input() channel: any;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  label?: string;
 | 
					  label?: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit {
 | 
				
			|||||||
    this.network = stateService.network;
 | 
					    this.network = stateService.network;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnInit() {
 | 
					  ngOnChanges() {
 | 
				
			||||||
    if (this.vin) {
 | 
					    if (this.channel) {
 | 
				
			||||||
 | 
					      this.handleChannel();
 | 
				
			||||||
 | 
					    } else if (this.vin) {
 | 
				
			||||||
      this.handleVin();
 | 
					      this.handleVin();
 | 
				
			||||||
    } else if (this.vout) {
 | 
					    } else if (this.vout) {
 | 
				
			||||||
      this.handleVout();
 | 
					      this.handleVout();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleChannel() {
 | 
				
			||||||
 | 
					    const type = this.vout ? 'open' : 'close';
 | 
				
			||||||
 | 
					    this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleVin() {
 | 
					  handleVin() {
 | 
				
			||||||
    if (this.vin.inner_witnessscript_asm) {
 | 
					    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) {
 | 
					      if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) {
 | 
				
			||||||
 | 
				
			|||||||
@ -55,10 +55,7 @@
 | 
				
			|||||||
              <tr>
 | 
					              <tr>
 | 
				
			||||||
                <td i18n="block.timestamp">Timestamp</td>
 | 
					                <td i18n="block.timestamp">Timestamp</td>
 | 
				
			||||||
                <td>
 | 
					                <td>
 | 
				
			||||||
                  ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
 | 
					                  <app-timestamp [unixTime]="block.timestamp"></app-timestamp>
 | 
				
			||||||
                  <div class="lg-inline">
 | 
					 | 
				
			||||||
                    <i class="symbol">(<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>)</i>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </td>
 | 
					                </td>
 | 
				
			||||||
              </tr>
 | 
					              </tr>
 | 
				
			||||||
              <tr>
 | 
					              <tr>
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										3
									
								
								frontend/src/app/components/change/change.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								frontend/src/app/components/change/change.component.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					<span [style]="change >= 0 ? 'color: #42B747' : 'color: #B74242'">
 | 
				
			||||||
 | 
					  {{ change >= 0 ? '+' : '' }}{{ change | amountShortener }}%
 | 
				
			||||||
 | 
					</span>
 | 
				
			||||||
							
								
								
									
										25
									
								
								frontend/src/app/components/change/change.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								frontend/src/app/components/change/change.component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					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 {
 | 
				
			||||||
 | 
					    if (!this.previous) {
 | 
				
			||||||
 | 
					      this.change = 0;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      this.change = (this.current - this.previous) / this.previous * 100;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;">
 | 
					<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"> 
 | 
					  <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="13">
 | 
					    <img src="./resources/clippy.svg" [width]="size === 'small' ? 10 : 13">
 | 
				
			||||||
  </button>
 | 
					  </button>
 | 
				
			||||||
</span>
 | 
					</span>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,8 @@
 | 
				
			|||||||
.btn-link {
 | 
					.btn-link {
 | 
				
			||||||
  padding: 0.25rem 0 0.1rem 0.5rem;
 | 
					  padding: 0.25rem 0 0.1rem 0.5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					img {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  left: -3px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -11,6 +11,7 @@ import * as tlite from 'tlite';
 | 
				
			|||||||
export class ClipboardComponent implements AfterViewInit {
 | 
					export class ClipboardComponent implements AfterViewInit {
 | 
				
			||||||
  @ViewChild('btn') btn: ElementRef;
 | 
					  @ViewChild('btn') btn: ElementRef;
 | 
				
			||||||
  @ViewChild('buttonWrapper') buttonWrapper: ElementRef;
 | 
					  @ViewChild('buttonWrapper') buttonWrapper: ElementRef;
 | 
				
			||||||
 | 
					  @Input() size: 'small' | 'normal' = 'normal';
 | 
				
			||||||
  @Input() text: string;
 | 
					  @Input() text: string;
 | 
				
			||||||
  copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
 | 
					  copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@ import { map } from 'rxjs/operators';
 | 
				
			|||||||
import { ApiService } from 'src/app/services/api.service';
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
import { formatNumber } from '@angular/common';
 | 
					import { formatNumber } from '@angular/common';
 | 
				
			||||||
import { selectPowerOfTen } from 'src/app/bitcoin.utils';
 | 
					import { selectPowerOfTen } from 'src/app/bitcoin.utils';
 | 
				
			||||||
 | 
					import { StateService } from 'src/app/services/state.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-difficulty-adjustments-table',
 | 
					  selector: 'app-difficulty-adjustments-table',
 | 
				
			||||||
@ -26,10 +27,16 @@ export class DifficultyAdjustmentsTable implements OnInit {
 | 
				
			|||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    @Inject(LOCALE_ID) public locale: string,
 | 
					    @Inject(LOCALE_ID) public locale: string,
 | 
				
			||||||
    private apiService: ApiService,
 | 
					    private apiService: ApiService,
 | 
				
			||||||
 | 
					    public stateService: StateService
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnInit(): void {
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					    let decimals = 2;
 | 
				
			||||||
 | 
					    if (this.stateService.network === 'signet') {
 | 
				
			||||||
 | 
					      decimals = 5;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.hashrateObservable$ = this.apiService.getDifficultyAdjustments$('3m')
 | 
					    this.hashrateObservable$ = this.apiService.getDifficultyAdjustments$('3m')
 | 
				
			||||||
      .pipe(
 | 
					      .pipe(
 | 
				
			||||||
        map((response) => {
 | 
					        map((response) => {
 | 
				
			||||||
@ -43,7 +50,7 @@ export class DifficultyAdjustmentsTable implements OnInit {
 | 
				
			|||||||
              change: (adjustment[3] - 1) * 100,
 | 
					              change: (adjustment[3] - 1) * 100,
 | 
				
			||||||
              difficultyShorten: formatNumber(
 | 
					              difficultyShorten: formatNumber(
 | 
				
			||||||
                adjustment[2] / selectedPowerOfTen.divider,
 | 
					                adjustment[2] / selectedPowerOfTen.divider,
 | 
				
			||||||
                this.locale, '1.2-2') + selectedPowerOfTen.unit
 | 
					                this.locale, `1.${decimals}-${decimals}`) + selectedPowerOfTen.unit
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
          this.isLoading = false;
 | 
					          this.isLoading = false;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,10 @@
 | 
				
			|||||||
<div *ngIf="stateService.env.MINING_DASHBOARD" class="mb-3 d-inline-flex menu" style="padding: 0px 35px;">
 | 
					<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING" class="mb-3 d-flex menu"
 | 
				
			||||||
 | 
					  style="padding: 0px 35px;">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <a routerLinkActive="active" class="btn btn-primary w-50 mr-1"
 | 
					  <a routerLinkActive="active" class="btn btn-primary w-50 mr-1"
 | 
				
			||||||
    [routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
 | 
					    [routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
 | 
				
			||||||
  <div ngbDropdown class="w-50">
 | 
					
 | 
				
			||||||
 | 
					  <div ngbDropdown class="w-50" *ngIf="stateService.env.MINING_DASHBOARD">
 | 
				
			||||||
    <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
 | 
					    <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
 | 
				
			||||||
    <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
					    <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
 | 
				
			||||||
@ -9,19 +12,30 @@
 | 
				
			|||||||
      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]"
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]"
 | 
				
			||||||
        i18n="mining.pools-dominance">Pools Dominance</a>
 | 
					        i18n="mining.pools-dominance">Pools Dominance</a>
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					      <a class="dropdown-item" routerLinkActive="active"
 | 
				
			||||||
        [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate & Difficulty</a>
 | 
					        [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate &
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					        Difficulty</a>
 | 
				
			||||||
        [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" i18n="mining.block-fee-rates">Block Fee Rates</a>
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]"
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					        i18n="mining.block-fee-rates">Block Fee Rates</a>
 | 
				
			||||||
        [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" i18n="mining.block-fees">Block Fees</a>
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					        i18n="mining.block-fees">Block Fees</a>
 | 
				
			||||||
        [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" i18n="mining.block-rewards">Block Rewards</a>
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="mining.block-rewards">Block Rewards</a>
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					      <a class="dropdown-item" routerLinkActive="active"
 | 
				
			||||||
        [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" i18n="mining.block-sizes-weights">Block Sizes and Weights</a>
 | 
					        [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" i18n="mining.block-sizes-weights">Block Sizes and Weights</a>
 | 
				
			||||||
      <a class="dropdown-item" routerLinkActive="active"
 | 
					      <a class="dropdown-item" routerLinkActive="active"
 | 
				
			||||||
        [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</a>
 | 
					        [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</a>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div ngbDropdown class="w-50" *ngIf="stateService.env.LIGHTNING">
 | 
				
			||||||
 | 
					    <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
 | 
				
			||||||
 | 
					    <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.nodes-networks">Nodes per network</a>
 | 
				
			||||||
 | 
					      <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
 | 
				
			||||||
 | 
					        i18n="lightning.capacity">Network capacity</a>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<router-outlet></router-outlet>
 | 
					<router-outlet></router-outlet>
 | 
				
			||||||
 | 
				
			|||||||
@ -12,8 +12,11 @@
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="item">
 | 
					      <div class="item">
 | 
				
			||||||
        <h5 class="card-title" i18n="block.difficulty">Difficulty</h5>
 | 
					        <h5 class="card-title" i18n="block.difficulty">Difficulty</h5>
 | 
				
			||||||
        <p class="card-text">
 | 
					        <p class="card-text" *ngIf="network === 'signet'">
 | 
				
			||||||
          {{ hashrates.currentDifficulty | amountShortener }}
 | 
					          {{ hashrates.currentDifficulty | amountShortener : 5 }}
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					        <p class="card-text" *ngIf="network !== 'signet'">
 | 
				
			||||||
 | 
					          {{ hashrates.currentDifficulty | amountShortener : 2 }}
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -335,6 +335,9 @@ export class HashrateChartComponent implements OnInit {
 | 
				
			|||||||
          axisLabel: {
 | 
					          axisLabel: {
 | 
				
			||||||
            color: 'rgb(110, 112, 121)',
 | 
					            color: 'rgb(110, 112, 121)',
 | 
				
			||||||
            formatter: (val) => {
 | 
					            formatter: (val) => {
 | 
				
			||||||
 | 
					              if (this.stateService.network === 'signet') {
 | 
				
			||||||
 | 
					                return val;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
              const selectedPowerOfTen: any = selectPowerOfTen(val);
 | 
					              const selectedPowerOfTen: any = selectPowerOfTen(val);
 | 
				
			||||||
              const newVal = Math.round(val / selectedPowerOfTen.divider);
 | 
					              const newVal = Math.round(val / selectedPowerOfTen.divider);
 | 
				
			||||||
              return `${newVal} ${selectedPowerOfTen.unit}`;
 | 
					              return `${newVal} ${selectedPowerOfTen.unit}`;
 | 
				
			||||||
 | 
				
			|||||||
@ -35,6 +35,9 @@
 | 
				
			|||||||
      <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
 | 
					      <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>
 | 
					        <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>
 | 
				
			||||||
 | 
					      <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">
 | 
					      <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>
 | 
					        <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>
 | 
					      </li>
 | 
				
			||||||
 | 
				
			|||||||
@ -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 * as QRCode from 'qrcode';
 | 
				
			||||||
import { StateService } from 'src/app/services/state.service';
 | 
					import { StateService } from 'src/app/services/state.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Component({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-qrcode',
 | 
					  selector: 'app-qrcode',
 | 
				
			||||||
  templateUrl: './qrcode.component.html',
 | 
					  templateUrl: './qrcode.component.html',
 | 
				
			||||||
  styleUrls: ['./qrcode.component.scss']
 | 
					  styleUrls: ['./qrcode.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class QrcodeComponent implements AfterViewInit {
 | 
					export class QrcodeComponent implements AfterViewInit {
 | 
				
			||||||
  @Input() data: string;
 | 
					  @Input() data: string;
 | 
				
			||||||
@ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit {
 | 
				
			|||||||
    private stateService: StateService,
 | 
					    private stateService: StateService,
 | 
				
			||||||
  ) { }
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnChanges() {
 | 
				
			||||||
 | 
					    if (!this.canvas || !this.canvas.nativeElement) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.render();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngAfterViewInit() {
 | 
					  ngAfterViewInit() {
 | 
				
			||||||
 | 
					    this.render();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render() {
 | 
				
			||||||
    if (!this.stateService.isBrowser) {
 | 
					    if (!this.stateService.isBrowser) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,10 @@
 | 
				
			|||||||
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
 | 
					<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
 | 
				
			||||||
  <div class="d-flex">
 | 
					  <div class="d-flex">
 | 
				
			||||||
    <div class="search-box-container mr-2">
 | 
					    <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>
 | 
				
			||||||
    <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>
 | 
					      <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>
 | 
				
			||||||
 | 
				
			|||||||
@ -32,6 +32,7 @@ form {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.search-box-container {
 | 
					.search-box-container {
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
  width: 100%;
 | 
					  width: 100%;
 | 
				
			||||||
  @media (min-width: 768px) {
 | 
					  @media (min-width: 768px) {
 | 
				
			||||||
    min-width: 400px;
 | 
					    min-width: 400px;
 | 
				
			||||||
 | 
				
			|||||||
@ -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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
 | 
				
			||||||
import { Router } from '@angular/router';
 | 
					import { Router } from '@angular/router';
 | 
				
			||||||
import { AssetsService } from 'src/app/services/assets.service';
 | 
					import { AssetsService } from 'src/app/services/assets.service';
 | 
				
			||||||
import { StateService } from 'src/app/services/state.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 { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators';
 | 
				
			||||||
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
 | 
					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 { 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({
 | 
					@Component({
 | 
				
			||||||
  selector: 'app-search-form',
 | 
					  selector: 'app-search-form',
 | 
				
			||||||
  templateUrl: './search-form.component.html',
 | 
					  templateUrl: './search-form.component.html',
 | 
				
			||||||
  styleUrls: ['./search-form.component.scss'],
 | 
					  styleUrls: ['./search-form.component.scss'],
 | 
				
			||||||
  changeDetection: ChangeDetectionStrategy.OnPush
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class SearchFormComponent implements OnInit {
 | 
					export class SearchFormComponent implements OnInit {
 | 
				
			||||||
  network = '';
 | 
					  network = '';
 | 
				
			||||||
  assets: object = {};
 | 
					  assets: object = {};
 | 
				
			||||||
  isSearching = false;
 | 
					  isSearching = false;
 | 
				
			||||||
  typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>);
 | 
					  typeAhead$: Observable<any>;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  searchForm: FormGroup;
 | 
					  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})$/;
 | 
					  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}$/;
 | 
					  regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
 | 
				
			||||||
  regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
 | 
					  regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/;
 | 
				
			||||||
  regexBlockheight = /^[0-9]+$/;
 | 
					  regexBlockheight = /^[0-9]+$/;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @ViewChild('instance', {static: true}) instance: NgbTypeahead;
 | 
					 | 
				
			||||||
  focus$ = new Subject<string>();
 | 
					  focus$ = new Subject<string>();
 | 
				
			||||||
  click$ = 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(
 | 
					  constructor(
 | 
				
			||||||
    private formBuilder: FormBuilder,
 | 
					    private formBuilder: FormBuilder,
 | 
				
			||||||
@ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit {
 | 
				
			|||||||
    private assetsService: AssetsService,
 | 
					    private assetsService: AssetsService,
 | 
				
			||||||
    private stateService: StateService,
 | 
					    private stateService: StateService,
 | 
				
			||||||
    private electrsApiService: ElectrsApiService,
 | 
					    private electrsApiService: ElectrsApiService,
 | 
				
			||||||
 | 
					    private apiService: ApiService,
 | 
				
			||||||
    private relativeUrlPipe: RelativeUrlPipe,
 | 
					    private relativeUrlPipe: RelativeUrlPipe,
 | 
				
			||||||
    private shortenStringPipe: ShortenStringPipe,
 | 
					 | 
				
			||||||
  ) { }
 | 
					  ) { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ngOnInit() {
 | 
					  ngOnInit() {
 | 
				
			||||||
    this.typeaheadSearchFn = this.typeaheadSearch;
 | 
					 | 
				
			||||||
    this.stateService.networkChanged$.subscribe((network) => this.network = network);
 | 
					    this.stateService.networkChanged$.subscribe((network) => this.network = network);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.searchForm = this.formBuilder.group({
 | 
					    this.searchForm = this.formBuilder.group({
 | 
				
			||||||
@ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit {
 | 
				
			|||||||
          this.assets = assets;
 | 
					          this.assets = assets;
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  typeaheadSearch = (text$: Observable<string>) => {
 | 
					    this.typeAhead$ = this.searchForm.get('searchText').valueChanges
 | 
				
			||||||
    const debouncedText$ = text$.pipe(
 | 
					      .pipe(
 | 
				
			||||||
        map((text) => {
 | 
					        map((text) => {
 | 
				
			||||||
          if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
 | 
					          if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
 | 
				
			||||||
            return text.substr(1);
 | 
					            return text.substr(1);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        return text;
 | 
					          return text.trim();
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
      debounceTime(200),
 | 
					        debounceTime(250),
 | 
				
			||||||
      distinctUntilChanged()
 | 
					        distinctUntilChanged(),
 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen()));
 | 
					 | 
				
			||||||
    const inputFocus$ = this.focus$;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$)
 | 
					 | 
				
			||||||
      .pipe(
 | 
					 | 
				
			||||||
        switchMap((text) => {
 | 
					        switchMap((text) => {
 | 
				
			||||||
          if (!text.length) {
 | 
					          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') {
 | 
					          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() {
 | 
					  itemSelected() {
 | 
				
			||||||
    setTimeout(() => this.search());
 | 
					    setTimeout(() => this.search());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  search() {
 | 
					  selectedResult(result: any) {
 | 
				
			||||||
    const searchText = this.searchForm.value.searchText.trim();
 | 
					    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) {
 | 
					    if (searchText) {
 | 
				
			||||||
      this.isSearching = true;
 | 
					      this.isSearching = true;
 | 
				
			||||||
      if (this.regexAddress.test(searchText)) {
 | 
					      if (this.regexAddress.test(searchText)) {
 | 
				
			||||||
 | 
				
			|||||||
@ -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>  <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>  <span class="symbol">{{ channel.id }}</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </ng-template>
 | 
				
			||||||
 | 
					  </ng-template>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -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%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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--;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -13,6 +13,7 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
 | 
				
			|||||||
  intervals = {};
 | 
					  intervals = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Input() time: number;
 | 
					  @Input() time: number;
 | 
				
			||||||
 | 
					  @Input() dateString: number;
 | 
				
			||||||
  @Input() fastRender = false;
 | 
					  @Input() fastRender = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
@ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  calculate() {
 | 
					  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) {
 | 
					    if (seconds < 60) {
 | 
				
			||||||
      return $localize`:@@date-base.just-now:Just now`;
 | 
					      return $localize`:@@date-base.just-now:Just now`;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -77,7 +77,7 @@
 | 
				
			|||||||
                          {{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
 | 
					                          {{ vin.prevout.scriptpubkey_type?.toUpperCase() }}
 | 
				
			||||||
                        </ng-template>
 | 
					                        </ng-template>
 | 
				
			||||||
                        <div>
 | 
					                        <div>
 | 
				
			||||||
                          <app-address-labels [vin]="vin"></app-address-labels>
 | 
					                          <app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels>
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                      </ng-template>
 | 
					                      </ng-template>
 | 
				
			||||||
                    </ng-container>
 | 
					                    </ng-container>
 | 
				
			||||||
@ -172,7 +172,7 @@
 | 
				
			|||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                  </a>
 | 
					                  </a>
 | 
				
			||||||
                  <div>
 | 
					                  <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>
 | 
					                  </div>
 | 
				
			||||||
                  <ng-template #scriptpubkey_type>
 | 
					                  <ng-template #scriptpubkey_type>
 | 
				
			||||||
                    <ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
 | 
					                    <ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,11 @@
 | 
				
			|||||||
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
 | 
					import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
 | 
				
			||||||
import { StateService } from '../../services/state.service';
 | 
					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 { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface';
 | 
				
			||||||
import { ElectrsApiService } from '../../services/electrs-api.service';
 | 
					import { ElectrsApiService } from '../../services/electrs-api.service';
 | 
				
			||||||
import { environment } from 'src/environments/environment';
 | 
					import { environment } from 'src/environments/environment';
 | 
				
			||||||
import { AssetsService } from 'src/app/services/assets.service';
 | 
					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 { BlockExtended } from 'src/app/interfaces/node-api.interface';
 | 
				
			||||||
import { ApiService } from 'src/app/services/api.service';
 | 
					import { ApiService } from 'src/app/services/api.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
				
			|||||||
  latestBlock$: Observable<BlockExtended>;
 | 
					  latestBlock$: Observable<BlockExtended>;
 | 
				
			||||||
  outspendsSubscription: Subscription;
 | 
					  outspendsSubscription: Subscription;
 | 
				
			||||||
  refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
 | 
					  refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
 | 
				
			||||||
 | 
					  refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
 | 
				
			||||||
  showDetails$ = new BehaviorSubject<boolean>(false);
 | 
					  showDetails$ = new BehaviorSubject<boolean>(false);
 | 
				
			||||||
  outspends: Outspend[][] = [];
 | 
					  outspends: Outspend[][] = [];
 | 
				
			||||||
  assetsMinimal: any;
 | 
					  assetsMinimal: any;
 | 
				
			||||||
 | 
					  channels: { inputs: any[], outputs: any[] };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    public stateService: StateService,
 | 
					    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());
 | 
					    ).subscribe(() => this.ref.markForCheck());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
 | 
				
			|||||||
        tx['addressValue'] = addressIn - addressOut;
 | 
					        tx['addressValue'] = addressIn - addressOut;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    const txIds = this.transactions.map((tx) => tx.txid);
 | 
				
			||||||
    this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid));
 | 
					    this.refreshOutspends$.next(txIds);
 | 
				
			||||||
 | 
					    this.refreshChannels$.next(txIds);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onScroll() {
 | 
					  onScroll() {
 | 
				
			||||||
 | 
				
			|||||||
@ -57,6 +57,9 @@ import { CommonModule } from '@angular/common';
 | 
				
			|||||||
    NgxEchartsModule.forRoot({
 | 
					    NgxEchartsModule.forRoot({
 | 
				
			||||||
      echarts: () => import('echarts')
 | 
					      echarts: () => import('echarts')
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  exports: [
 | 
				
			||||||
 | 
					    NgxEchartsModule,
 | 
				
			||||||
  ]
 | 
					  ]
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class GraphsModule { }
 | 
					export class GraphsModule { }
 | 
				
			||||||
 | 
				
			|||||||
@ -18,6 +18,8 @@ import { StartComponent } from '../components/start/start.component';
 | 
				
			|||||||
import { StatisticsComponent } from '../components/statistics/statistics.component';
 | 
					import { StatisticsComponent } from '../components/statistics/statistics.component';
 | 
				
			||||||
import { TelevisionComponent } from '../components/television/television.component';
 | 
					import { TelevisionComponent } from '../components/television/television.component';
 | 
				
			||||||
import { DashboardComponent } from '../dashboard/dashboard.component';
 | 
					import { DashboardComponent } from '../dashboard/dashboard.component';
 | 
				
			||||||
 | 
					import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
 | 
				
			||||||
 | 
					import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const browserWindow = window || {};
 | 
					const browserWindow = window || {};
 | 
				
			||||||
// @ts-ignore
 | 
					// @ts-ignore
 | 
				
			||||||
@ -89,6 +91,14 @@ const routes: Routes = [
 | 
				
			|||||||
            path: 'mining/block-sizes-weights',
 | 
					            path: 'mining/block-sizes-weights',
 | 
				
			||||||
            component: BlockSizesWeightsGraphComponent,
 | 
					            component: BlockSizesWeightsGraphComponent,
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/nodes-networks',
 | 
				
			||||||
 | 
					            component: NodesNetworksChartComponent,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            path: 'lightning/capacity',
 | 
				
			||||||
 | 
					            component: LightningStatisticsChartComponent,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            path: '',
 | 
					            path: '',
 | 
				
			||||||
            redirectTo: 'mempool',
 | 
					            redirectTo: 'mempool',
 | 
				
			||||||
 | 
				
			|||||||
@ -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>
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@ -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() { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										96
									
								
								frontend/src/app/lightning/channel/channel.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								frontend/src/app/lightning/channel/channel.component.html
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										41
									
								
								frontend/src/app/lightning/channel/channel.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								frontend/src/app/lightning/channel/channel.component.scss
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								frontend/src/app/lightning/channel/channel.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/src/app/lightning/channel/channel.component.ts
									
									
									
									
									
										Normal 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);
 | 
				
			||||||
 | 
					              })
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					<span class="badge badge-pill badge-{{ label.class }}" >{{ label.label }}</span>
 | 
				
			||||||
@ -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',
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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"> </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>
 | 
				
			||||||
@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					.second-line {
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,126 @@
 | 
				
			|||||||
 | 
					<div class="widget-toggler">
 | 
				
			||||||
 | 
					  <a href="javascript:;" (click)="switchMode('avg')" class="toggler-option"
 | 
				
			||||||
 | 
					    [ngClass]="{'inactive': mode !== 'avg'}"><small>avg</small></a>
 | 
				
			||||||
 | 
					  <span style="color: #ffffff66; font-size: 8px"> | </span>
 | 
				
			||||||
 | 
					  <a href="javascript:;" (click)="switchMode('med')" class="toggler-option"
 | 
				
			||||||
 | 
					    [ngClass]="{'inactive': mode !== 'med'}"><small>med</small></a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="fee-estimation-container" *ngIf="mode === 'avg'">
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.average-capacity">Avg Capacity</h5>
 | 
				
			||||||
 | 
					      <div class="card-text">
 | 
				
			||||||
 | 
					        <div class="fee-text">
 | 
				
			||||||
 | 
					          {{ statistics.latest?.avg_capacity || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					          <span i18n="shared.sat-vbyte|sat/vB">sats</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span class="fiat">
 | 
				
			||||||
 | 
					          <app-change [current]="statistics.latest?.avg_capacity" [previous]="statistics.previous?.avg_capacity"></app-change>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.average-feerate">Avg Fee Rate</h5>
 | 
				
			||||||
 | 
					      <div class="card-text" i18n-ngbTooltip="ln.average-feerate-desc"
 | 
				
			||||||
 | 
					        ngbTooltip="The average fee rate charged by routing nodes, ignoring fee rates > 0.5% or 5000ppm"
 | 
				
			||||||
 | 
					        placement="bottom">
 | 
				
			||||||
 | 
					        <div class="fee-text">
 | 
				
			||||||
 | 
					          {{ statistics.latest?.avg_fee_rate || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					          <span i18n="shared.sat-vbyte|sat/vB">ppm</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span class="fiat">
 | 
				
			||||||
 | 
					          <app-change [current]="statistics.latest?.avg_fee_rate" [previous]="statistics.previous?.avg_fee_rate"></app-change>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.average-basefee">Avg Base Fee</h5>
 | 
				
			||||||
 | 
					      <div class="card-text" i18n-ngbTooltip="ln.average-basefee-desc"
 | 
				
			||||||
 | 
					        ngbTooltip="The average base fee charged by routing nodes, ignoring base fees > 5000ppm" placement="bottom">
 | 
				
			||||||
 | 
					        <div class="card-text">
 | 
				
			||||||
 | 
					          <div class="fee-text">
 | 
				
			||||||
 | 
					            {{ statistics.latest?.avg_base_fee_mtokens || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					            <span i18n="shared.sat-vbyte|sat/vB">msats</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <span class="fiat">
 | 
				
			||||||
 | 
					            <app-change [current]="statistics.latest?.avg_base_fee_mtokens" [previous]="statistics.previous?.avg_base_fee_mtokens"></app-change>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="fee-estimation-container" *ngIf="mode === 'med'">
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.median-capacity">Med Capacity</h5>
 | 
				
			||||||
 | 
					      <div class="card-text">
 | 
				
			||||||
 | 
					        <div class="fee-text">
 | 
				
			||||||
 | 
					          {{ statistics.latest?.med_capacity || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					          <span i18n="shared.sat-vbyte|sat/vB">sats</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span class="fiat">
 | 
				
			||||||
 | 
					          <app-change [current]="statistics.latest?.med_capacity" [previous]="statistics.previous?.med_capacity"></app-change>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.average-feerate">Med Fee Rate</h5>
 | 
				
			||||||
 | 
					      <div class="card-text" i18n-ngbTooltip="ln.median-feerate-desc"
 | 
				
			||||||
 | 
					        ngbTooltip="The average fee rate charged by routing nodes, ignoring fee rates > 0.5% or 5000ppm"
 | 
				
			||||||
 | 
					        placement="bottom">
 | 
				
			||||||
 | 
					        <div class="fee-text">
 | 
				
			||||||
 | 
					          {{ statistics.latest?.med_fee_rate || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					          <span i18n="shared.sat-vbyte|sat/vB">ppm</span>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span class="fiat">
 | 
				
			||||||
 | 
					          <app-change [current]="statistics.latest?.med_fee_rate" [previous]="statistics.previous?.med_fee_rate"></app-change>
 | 
				
			||||||
 | 
					        </span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="item">
 | 
				
			||||||
 | 
					      <h5 class="card-title" i18n="ln.median-basefee">Med Base Fee</h5>
 | 
				
			||||||
 | 
					      <div class="card-text" i18n-ngbTooltip="ln.median-basefee-desc"
 | 
				
			||||||
 | 
					        ngbTooltip="The median base fee charged by routing nodes, ignoring base fees > 5000ppm" placement="bottom">
 | 
				
			||||||
 | 
					        <div class="card-text">
 | 
				
			||||||
 | 
					          <div class="fee-text">
 | 
				
			||||||
 | 
					            {{ statistics.latest?.med_base_fee_mtokens || 0 | number: '1.0-0' }}
 | 
				
			||||||
 | 
					            <span i18n="shared.sat-vbyte|sat/vB">msats</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <span class="fiat">
 | 
				
			||||||
 | 
					          <app-change [current]="statistics.latest?.med_base_fee_mtokens" [previous]="statistics.previous?.med_base_fee_mtokens"></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>
 | 
				
			||||||
@ -0,0 +1,101 @@
 | 
				
			|||||||
 | 
					.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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.widget-toggler {
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: -20px;
 | 
				
			||||||
 | 
					  right: 3px;
 | 
				
			||||||
 | 
					  text-align: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.toggler-option {
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.inactive {
 | 
				
			||||||
 | 
					  color: #ffffff66;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 | 
				
			||||||
 | 
					import { Observable } from 'rxjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Component({
 | 
				
			||||||
 | 
					  selector: 'app-channels-statistics',
 | 
				
			||||||
 | 
					  templateUrl: './channels-statistics.component.html',
 | 
				
			||||||
 | 
					  styleUrls: ['./channels-statistics.component.scss'],
 | 
				
			||||||
 | 
					  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class ChannelsStatisticsComponent implements OnInit {
 | 
				
			||||||
 | 
					  @Input() statistics$: Observable<any>;
 | 
				
			||||||
 | 
					  mode: string = 'avg';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor() { }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  ngOnInit(): void {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  switchMode(mode: 'avg' | 'med') {
 | 
				
			||||||
 | 
					    this.mode = mode;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										65
									
								
								frontend/src/app/lightning/lightning-api.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								frontend/src/app/lightning/lightning-api.service.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					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');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  listChannelStats$(publicKey: string): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any>(this.apiBasePath + '/channels/' + publicKey + '/statistics');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  listStatistics$(interval: string | undefined): Observable<any> {
 | 
				
			||||||
 | 
					    return this.httpClient.get<any>(
 | 
				
			||||||
 | 
					      this.apiBasePath + '/api/v1/lightning/statistics' +
 | 
				
			||||||
 | 
					      (interval !== undefined ? `/${interval}` : ''), { observe: 'response' }
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					<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> 
 | 
				
			||||||
 | 
					      </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> 
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="card-wrapper">
 | 
				
			||||||
 | 
					        <div class="card" style="height: 123px">
 | 
				
			||||||
 | 
					          <div class="card-body more-padding">
 | 
				
			||||||
 | 
					            <app-channels-statistics [statistics$]="statistics$"></app-channels-statistics>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col">
 | 
				
			||||||
 | 
					      <div class="card">
 | 
				
			||||||
 | 
					        <div class="card-body">
 | 
				
			||||||
 | 
					          <app-nodes-networks-chart [widget]=true></app-nodes-networks-chart>
 | 
				
			||||||
 | 
					          <div class="mt-1"><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col">
 | 
				
			||||||
 | 
					      <div class="card">
 | 
				
			||||||
 | 
					        <div class="card-body">
 | 
				
			||||||
 | 
					          <app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
 | 
				
			||||||
 | 
					          <div class="mt-1"><a [routerLink]="['/graphs/lightning/capacity' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </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 »</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 »</a></div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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$().pipe(share());
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					<router-outlet></router-outlet>
 | 
				
			||||||
@ -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']);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										48
									
								
								frontend/src/app/lightning/lightning.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/app/lightning/lightning.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,48 @@
 | 
				
			|||||||
 | 
					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';
 | 
				
			||||||
 | 
					import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
 | 
				
			||||||
 | 
					import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
 | 
				
			||||||
 | 
					@NgModule({
 | 
				
			||||||
 | 
					  declarations: [
 | 
				
			||||||
 | 
					    LightningDashboardComponent,
 | 
				
			||||||
 | 
					    NodesListComponent,
 | 
				
			||||||
 | 
					    NodeStatisticsComponent,
 | 
				
			||||||
 | 
					    NodeStatisticsChartComponent,
 | 
				
			||||||
 | 
					    NodeComponent,
 | 
				
			||||||
 | 
					    ChannelsListComponent,
 | 
				
			||||||
 | 
					    ChannelComponent,
 | 
				
			||||||
 | 
					    LightningWrapperComponent,
 | 
				
			||||||
 | 
					    ChannelBoxComponent,
 | 
				
			||||||
 | 
					    ClosingTypeComponent,
 | 
				
			||||||
 | 
					    LightningStatisticsChartComponent,
 | 
				
			||||||
 | 
					    NodesNetworksChartComponent,
 | 
				
			||||||
 | 
					    ChannelsStatisticsComponent,
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  imports: [
 | 
				
			||||||
 | 
					    CommonModule,
 | 
				
			||||||
 | 
					    SharedModule,
 | 
				
			||||||
 | 
					    RouterModule,
 | 
				
			||||||
 | 
					    LightningRoutingModule,
 | 
				
			||||||
 | 
					    GraphsModule,
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  providers: [
 | 
				
			||||||
 | 
					    LightningApiService,
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					export class LightningModule { }
 | 
				
			||||||
							
								
								
									
										41
									
								
								frontend/src/app/lightning/lightning.routing.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								frontend/src/app/lightning/lightning.routing.module.ts
									
									
									
									
									
										Normal 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 { }
 | 
				
			||||||
@ -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>
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -0,0 +1,80 @@
 | 
				
			|||||||
 | 
					<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">
 | 
				
			||||||
 | 
					        <div class="fee-text">
 | 
				
			||||||
 | 
					          <app-amount [satoshis]="statistics.latest?.total_capacity" digitsInfo="1.2-2"></app-amount>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <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>
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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 {
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										100
									
								
								frontend/src/app/lightning/node/node.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								frontend/src/app/lightning/node/node.component.html
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
							
								
								
									
										60
									
								
								frontend/src/app/lightning/node/node.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/src/app/lightning/node/node.component.scss
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										64
									
								
								frontend/src/app/lightning/node/node.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/app/lightning/node/node.component.ts
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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>
 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user