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_RETRY_INTERVAL": 0, | ||||
|     "USER_AGENT": "mempool", | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "debug" | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "debug", | ||||
|     "AUTOMATIC_BLOCK_REINDEXING": false | ||||
|   }, | ||||
|   "CORE_RPC": { | ||||
|     "HOST": "127.0.0.1", | ||||
| @ -66,6 +67,15 @@ | ||||
|     "ENABLED": false, | ||||
|     "DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db" | ||||
|   }, | ||||
|   "LIGHTNING": { | ||||
|     "ENABLED": false, | ||||
|     "BACKEND": "lnd" | ||||
|   }, | ||||
|   "LND": { | ||||
|     "TLS_CERT_PATH": "tls.cert", | ||||
|     "MACAROON_PATH": "admin.macaroon", | ||||
|     "SOCKET": "localhost:10009" | ||||
|   }, | ||||
|   "SOCKS5PROXY": { | ||||
|     "ENABLED": false, | ||||
|     "USE_ONION": true, | ||||
|  | ||||
							
								
								
									
										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", | ||||
|   "version": "2.4.1-dev", | ||||
|   "version": "2.5.0-dev", | ||||
|   "description": "Bitcoin mempool visualizer and blockchain explorer backend", | ||||
|   "license": "GNU Affero General Public License v3.0", | ||||
|   "homepage": "https://mempool.space", | ||||
| @ -34,8 +34,10 @@ | ||||
|     "@types/node": "^16.11.41", | ||||
|     "axios": "~0.27.2", | ||||
|     "bitcoinjs-lib": "6.0.1", | ||||
|     "bolt07": "^1.8.1", | ||||
|     "crypto-js": "^4.0.0", | ||||
|     "express": "^4.18.0", | ||||
|     "lightning": "^5.16.3", | ||||
|     "mysql2": "2.3.3", | ||||
|     "node-worker-threads-pool": "^1.5.1", | ||||
|     "socks-proxy-agent": "~7.0.0", | ||||
|  | ||||
| @ -13,6 +13,7 @@ export interface AbstractBitcoinApi { | ||||
|   $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; | ||||
|   $getAddressPrefix(prefix: string): string[]; | ||||
|   $sendRawTransaction(rawTransaction: string): Promise<string>; | ||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; | ||||
|   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; | ||||
|   $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; | ||||
| } | ||||
|  | ||||
| @ -130,6 +130,16 @@ class BitcoinApi implements AbstractBitcoinApi { | ||||
|     return this.bitcoindClient.sendRawTransaction(rawTransaction); | ||||
|   } | ||||
| 
 | ||||
|   async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { | ||||
|     const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); | ||||
|     return { | ||||
|       spent: txOut === null, | ||||
|       status: { | ||||
|         confirmed: true, | ||||
|       } | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> { | ||||
|     const outSpends: IEsploraApi.Outspend[] = []; | ||||
|     const tx = await this.$getRawTransaction(txId, true, false); | ||||
| @ -195,7 +205,9 @@ class BitcoinApi implements AbstractBitcoinApi { | ||||
|         sequence: vin.sequence, | ||||
|         txid: vin.txid || '', | ||||
|         vout: vin.vout || 0, | ||||
|         witness: vin.txinwitness, | ||||
|         witness: vin.txinwitness || [], | ||||
|         inner_redeemscript_asm: '', | ||||
|         inner_witnessscript_asm: '', | ||||
|       }; | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -25,10 +25,10 @@ export namespace IEsploraApi { | ||||
|     is_coinbase: boolean; | ||||
|     scriptsig: string; | ||||
|     scriptsig_asm: string; | ||||
|     inner_redeemscript_asm?: string; | ||||
|     inner_witnessscript_asm?: string; | ||||
|     inner_redeemscript_asm: string; | ||||
|     inner_witnessscript_asm: string; | ||||
|     sequence: any; | ||||
|     witness?: string[]; | ||||
|     witness: string[]; | ||||
|     prevout: Vout | null; | ||||
|     // Elements
 | ||||
|     is_pegin?: boolean; | ||||
|  | ||||
| @ -66,6 +66,11 @@ class ElectrsApi implements AbstractBitcoinApi { | ||||
|     throw new Error('Method not implemented.'); | ||||
|   } | ||||
| 
 | ||||
|   $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> { | ||||
|     return axios.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) | ||||
|       .then((response) => response.data); | ||||
|   } | ||||
| 
 | ||||
|   $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> { | ||||
|     return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) | ||||
|       .then((response) => response.data); | ||||
|  | ||||
| @ -579,17 +579,13 @@ class Blocks { | ||||
|   } | ||||
| 
 | ||||
|   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[] = []; | ||||
| 
 | ||||
|     if (currentHeight < 0) { | ||||
|       return returnBlocks; | ||||
|     } | ||||
| 
 | ||||
|     if (currentHeight === 0 && Common.indexingEnabled()) { | ||||
|       currentHeight = await blocksRepository.$mostRecentBlockHeight(); | ||||
|     } | ||||
| 
 | ||||
|     // Check if block height exist in local cache to skip the hash lookup
 | ||||
|     const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight); | ||||
|     let startFromHash: string | null = null; | ||||
|  | ||||
| @ -4,7 +4,7 @@ import logger from '../logger'; | ||||
| import { Common } from './common'; | ||||
| 
 | ||||
| class DatabaseMigration { | ||||
|   private static currentVersion = 24; | ||||
|   private static currentVersion = 27; | ||||
|   private queryTimeout = 120000; | ||||
|   private statisticsAddedIndexed = false; | ||||
|   private uniqueLogs: string[] = []; | ||||
| @ -248,6 +248,32 @@ class DatabaseMigration { | ||||
|         await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`'); | ||||
|         await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits')); | ||||
|       } | ||||
| 
 | ||||
|       if (databaseSchemaVersion < 25 && isBitcoin === true) { | ||||
|         await this.$executeQuery(`INSERT INTO state VALUES('last_node_stats', 0, '1970-01-01');`); | ||||
|         await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats')); | ||||
|         await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes')); | ||||
|         await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels')); | ||||
|         await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats')); | ||||
|       } | ||||
| 
 | ||||
|       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) { | ||||
|       throw e; | ||||
|     } | ||||
| @ -572,6 +598,82 @@ class DatabaseMigration { | ||||
|       ) 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 { | ||||
|     return `CREATE TABLE IF NOT EXISTS blocks_audits (
 | ||||
|       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> { | ||||
|     const now = new Date(); | ||||
| 
 | ||||
|     try { | ||||
|     const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing'); | ||||
| 
 | ||||
|     // Run only if:
 | ||||
| @ -184,14 +182,15 @@ class Mining { | ||||
|     if (!runIndexing) { | ||||
|       return; | ||||
|     } | ||||
|     } catch (e) { | ||||
|       throw e; | ||||
|     } | ||||
| 
 | ||||
|     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 hashrates: any[] = []; | ||||
|       const genesisTimestamp = 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
 | ||||
|   | ||||
|       const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7)); | ||||
|       const lastMondayMidnight = this.getDateMidnight(lastMonday); | ||||
| @ -207,7 +206,7 @@ class Mining { | ||||
|       logger.debug(`Indexing weekly mining pool hashrate`); | ||||
|       loadingIndicators.setProgress('weekly-hashrate-indexing', 0); | ||||
| 
 | ||||
|       while (toTimestamp > genesisTimestamp) { | ||||
|       while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) { | ||||
|         const fromTimestamp = toTimestamp - 604800000; | ||||
| 
 | ||||
|         // Skip already indexed weeks
 | ||||
| @ -217,14 +216,6 @@ class Mining { | ||||
|           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( | ||||
|           null, fromTimestamp / 1000, toTimestamp / 1000); | ||||
|         const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount, | ||||
| @ -232,6 +223,7 @@ class Mining { | ||||
| 
 | ||||
|         let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000); | ||||
|         const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0); | ||||
|         if (totalBlocks > 0) { | ||||
|           pools = pools.map((pool: any) => { | ||||
|             pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate; | ||||
|             pool.share = (pool.blockCount / totalBlocks); | ||||
| @ -251,6 +243,7 @@ class Mining { | ||||
|           newlyIndexed += hashrates.length; | ||||
|           await HashratesRepository.$saveHashrates(hashrates); | ||||
|           hashrates.length = 0; | ||||
|         } | ||||
| 
 | ||||
|         const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer)); | ||||
|         if (elapsedSeconds > 1) { | ||||
| @ -285,20 +278,19 @@ class Mining { | ||||
|    * [INDEXING] Generate daily hashrate data | ||||
|    */ | ||||
|   public async $generateNetworkHashrateHistory(): Promise<void> { | ||||
|     try { | ||||
|     // We only run this once a day around midnight
 | ||||
|     const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing'); | ||||
|     const now = new Date().getUTCDate(); | ||||
|     if (now === latestRunDate) { | ||||
|       return; | ||||
|     } | ||||
|     } catch (e) { | ||||
|       throw e; | ||||
|     } | ||||
| 
 | ||||
|     const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp; | ||||
| 
 | ||||
|     try { | ||||
|       const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); | ||||
|       const genesisTimestamp = (config.MEMPOOL.NETWORK === 'signet') ? 1598918400000 : 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
 | ||||
|       const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0)); | ||||
|       const genesisTimestamp = genesisBlock.time * 1000; | ||||
|       const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); | ||||
|       const lastMidnight = this.getDateMidnight(new Date()); | ||||
|       let toTimestamp = Math.round(lastMidnight.getTime()); | ||||
|       const hashrates: any[] = []; | ||||
| @ -313,7 +305,7 @@ class Mining { | ||||
|       logger.debug(`Indexing daily network hashrate`); | ||||
|       loadingIndicators.setProgress('daily-hashrate-indexing', 0); | ||||
| 
 | ||||
|       while (toTimestamp > genesisTimestamp) { | ||||
|       while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) { | ||||
|         const fromTimestamp = toTimestamp - 86400000; | ||||
| 
 | ||||
|         // Skip already indexed days
 | ||||
| @ -323,17 +315,9 @@ class Mining { | ||||
|           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( | ||||
|           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); | ||||
| 
 | ||||
|         hashrates.push({ | ||||
| @ -368,8 +352,8 @@ class Mining { | ||||
|         ++totalIndexed; | ||||
|       } | ||||
| 
 | ||||
|       // Add genesis block manually on mainnet and testnet
 | ||||
|       if ('signet' !== config.MEMPOOL.NETWORK && toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) { | ||||
|       // Add genesis block manually
 | ||||
|       if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && !indexedTimestamp.includes(genesisTimestamp / 1000)) { | ||||
|         hashrates.push({ | ||||
|           hashrateTimestamp: genesisTimestamp / 1000, | ||||
|           avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1), | ||||
| @ -405,27 +389,37 @@ class Mining { | ||||
|     } | ||||
| 
 | ||||
|     const blocks: any = await BlocksRepository.$getBlocksDifficulty(); | ||||
| 
 | ||||
|     let currentDifficulty = 0; | ||||
|     const genesisBlock = await bitcoinClient.getBlock(await bitcoinClient.getBlockHash(0)); | ||||
|     let currentDifficulty = genesisBlock.difficulty; | ||||
|     let totalIndexed = 0; | ||||
| 
 | ||||
|     if (indexedHeights[0] !== true) { | ||||
|     if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) { | ||||
|       await DifficultyAdjustmentsRepository.$saveAdjustments({ | ||||
|         time: (config.MEMPOOL.NETWORK === 'signet') ? 1598918400 : 1231006505, | ||||
|         time: genesisBlock.time, | ||||
|         height: 0, | ||||
|         difficulty: (config.MEMPOOL.NETWORK === 'signet') ? 0.001126515290698186 : 1.0, | ||||
|         difficulty: currentDifficulty, | ||||
|         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) { | ||||
|       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; | ||||
|           } | ||||
|           continue;           | ||||
|         } | ||||
| 
 | ||||
|         let adjustment = block.difficulty / Math.max(1, currentDifficulty); | ||||
|         let adjustment = block.difficulty / currentDifficulty; | ||||
|         adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
 | ||||
| 
 | ||||
|         await DifficultyAdjustmentsRepository.$saveAdjustments({ | ||||
| @ -436,10 +430,20 @@ class Mining { | ||||
|         }); | ||||
| 
 | ||||
|         totalIndexed++; | ||||
|         if (block.height >= oldestConsecutiveBlock.height) { | ||||
|           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) { | ||||
|       logger.notice(`Indexed ${totalIndexed} difficulty adjustments`); | ||||
|     } | ||||
|  | ||||
| @ -222,6 +222,10 @@ class PoolsParser { | ||||
|    * Delete blocks which needs to be reindexed | ||||
|    */ | ||||
|    private async $deleteBlocskToReindex(finalPoolDataUpdate: any[]) { | ||||
|     if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const blockCount = await BlocksRepository.$blockCount(null, null); | ||||
|     if (blockCount === 0) { | ||||
|       return; | ||||
|  | ||||
| @ -23,10 +23,20 @@ interface IConfig { | ||||
|     EXTERNAL_RETRY_INTERVAL: number; | ||||
|     USER_AGENT: string; | ||||
|     STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug'; | ||||
|     AUTOMATIC_BLOCK_REINDEXING: boolean; | ||||
|   }; | ||||
|   ESPLORA: { | ||||
|     REST_API_URL: string; | ||||
|   }; | ||||
|   LIGHTNING: { | ||||
|     ENABLED: boolean; | ||||
|     BACKEND: 'lnd' | 'cln' | 'ldk'; | ||||
|   }; | ||||
|   LND: { | ||||
|     TLS_CERT_PATH: string; | ||||
|     MACAROON_PATH: string; | ||||
|     SOCKET: string; | ||||
|   }; | ||||
|   ELECTRUM: { | ||||
|     HOST: string; | ||||
|     PORT: number; | ||||
| @ -113,6 +123,7 @@ const defaults: IConfig = { | ||||
|     'EXTERNAL_RETRY_INTERVAL': 0, | ||||
|     'USER_AGENT': 'mempool', | ||||
|     'STDOUT_LOG_MIN_PRIORITY': 'debug', | ||||
|     'AUTOMATIC_BLOCK_REINDEXING': false, | ||||
|   }, | ||||
|   'ESPLORA': { | ||||
|     'REST_API_URL': 'http://127.0.0.1:3000', | ||||
| @ -158,6 +169,15 @@ const defaults: IConfig = { | ||||
|     'ENABLED': false, | ||||
|     'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db' | ||||
|   }, | ||||
|   'LIGHTNING': { | ||||
|     'ENABLED': false, | ||||
|     'BACKEND': 'lnd' | ||||
|   }, | ||||
|   'LND': { | ||||
|     'TLS_CERT_PATH': '', | ||||
|     'MACAROON_PATH': '', | ||||
|     'SOCKET': 'localhost:10009', | ||||
|   }, | ||||
|   'SOCKS5PROXY': { | ||||
|     'ENABLED': false, | ||||
|     'USE_ONION': true, | ||||
| @ -166,11 +186,11 @@ const defaults: IConfig = { | ||||
|     'USERNAME': '', | ||||
|     'PASSWORD': '' | ||||
|   }, | ||||
|   "PRICE_DATA_SERVER": { | ||||
|   'PRICE_DATA_SERVER': { | ||||
|     'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices', | ||||
|     'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices' | ||||
|   }, | ||||
|   "EXTERNAL_DATA_SERVER": { | ||||
|   'EXTERNAL_DATA_SERVER': { | ||||
|     'MEMPOOL_API': 'https://mempool.space/api/v1', | ||||
|     'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1', | ||||
|     'LIQUID_API': 'https://liquid.network/api/v1', | ||||
| @ -190,6 +210,8 @@ class Config implements IConfig { | ||||
|   SYSLOG: IConfig['SYSLOG']; | ||||
|   STATISTICS: IConfig['STATISTICS']; | ||||
|   BISQ: IConfig['BISQ']; | ||||
|   LIGHTNING: IConfig['LIGHTNING']; | ||||
|   LND: IConfig['LND']; | ||||
|   SOCKS5PROXY: IConfig['SOCKS5PROXY']; | ||||
|   PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER']; | ||||
|   EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER']; | ||||
| @ -205,6 +227,8 @@ class Config implements IConfig { | ||||
|     this.SYSLOG = configs.SYSLOG; | ||||
|     this.STATISTICS = configs.STATISTICS; | ||||
|     this.BISQ = configs.BISQ; | ||||
|     this.LIGHTNING = configs.LIGHTNING; | ||||
|     this.LND = configs.LND; | ||||
|     this.SOCKS5PROXY = configs.SOCKS5PROXY; | ||||
|     this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER; | ||||
|     this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER; | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import express from "express"; | ||||
| import { Application, Request, Response, NextFunction, Express } from 'express'; | ||||
| import { Application, Request, Response, NextFunction } from 'express'; | ||||
| import * as http from 'http'; | ||||
| import * as WebSocket from 'ws'; | ||||
| import cluster from 'cluster'; | ||||
| @ -28,6 +28,11 @@ import { Common } from './api/common'; | ||||
| import poolsUpdater from './tasks/pools-updater'; | ||||
| import indexer from './indexer'; | ||||
| import priceUpdater from './tasks/price-updater'; | ||||
| import nodesRoutes from './api/explorer/nodes.routes'; | ||||
| import channelsRoutes from './api/explorer/channels.routes'; | ||||
| import generalLightningRoutes from './api/explorer/general.routes'; | ||||
| import lightningStatsUpdater from './tasks/lightning/stats-updater.service'; | ||||
| import nodeSyncService from './tasks/lightning/node-sync.service'; | ||||
| import BlocksAuditsRepository from './repositories/BlocksAuditsRepository'; | ||||
| 
 | ||||
| class Server { | ||||
| @ -130,6 +135,11 @@ class Server { | ||||
|       bisqMarkets.startBisqService(); | ||||
|     } | ||||
| 
 | ||||
|     if (config.LIGHTNING.ENABLED) { | ||||
|       nodeSyncService.$startService() | ||||
|         .then(() => lightningStatsUpdater.$startService()); | ||||
|     } | ||||
| 
 | ||||
|     this.server.listen(config.MEMPOOL.HTTP_PORT, () => { | ||||
|       if (worker) { | ||||
|         logger.info(`Mempool Server worker #${process.pid} started`); | ||||
| @ -362,6 +372,12 @@ class Server { | ||||
|         .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth) | ||||
|         ; | ||||
|     } | ||||
| 
 | ||||
|     if (config.LIGHTNING.ENABLED) { | ||||
|       generalLightningRoutes.initRoutes(this.app); | ||||
|       nodesRoutes.initRoutes(this.app); | ||||
|       channelsRoutes.initRoutes(this.app); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -35,6 +35,8 @@ class Indexer { | ||||
|     this.runIndexer = false; | ||||
|     this.indexerRunning = true; | ||||
| 
 | ||||
|     logger.debug(`Running mining indexer`); | ||||
| 
 | ||||
|     try { | ||||
|       const chainValid = await blocks.$generateBlockDatabase(); | ||||
|       if (chainValid === false) { | ||||
| @ -54,9 +56,15 @@ class Indexer { | ||||
|       this.indexerRunning = false; | ||||
|       logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); | ||||
|       setTimeout(() => this.reindex(), 10000); | ||||
|       this.indexerRunning = false; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     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() { | ||||
|  | ||||
| @ -610,6 +610,24 @@ class BlocksRepository { | ||||
|       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(); | ||||
|  | ||||
| @ -46,9 +46,38 @@ class DifficultyAdjustmentsRepository { | ||||
|     query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`; | ||||
| 
 | ||||
|     if (descOrder === true) { | ||||
|       query += ` ORDER BY time DESC`; | ||||
|       query += ` ORDER BY height DESC`; | ||||
|     } 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 { | ||||
|  | ||||
| @ -1,6 +1,5 @@ | ||||
| import { escape } from 'mysql2'; | ||||
| import { Common } from '../api/common'; | ||||
| import config from '../config'; | ||||
| import DB from '../database'; | ||||
| import logger from '../logger'; | ||||
| 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[]> { | ||||
|     interval = Common.getSqlInterval(interval); | ||||
| 
 | ||||
|  | ||||
| @ -4,6 +4,12 @@ import { Prices } from '../tasks/price-updater'; | ||||
| 
 | ||||
| class PricesRepository { | ||||
|   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 { | ||||
|       await DB.query(` | ||||
|         INSERT INTO prices(time,             USD, EUR, GBP, CAD, CHF, AUD, JPY) | ||||
| @ -17,17 +23,17 @@ class PricesRepository { | ||||
|   } | ||||
| 
 | ||||
|   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; | ||||
|   } | ||||
| 
 | ||||
|   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; | ||||
|   } | ||||
| 
 | ||||
|   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); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -734,7 +734,7 @@ class Routes { | ||||
| 
 | ||||
|   public async $getDifficultyAdjustments(req: Request, res: Response) { | ||||
|     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('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); | ||||
| @ -790,7 +790,7 @@ class Routes { | ||||
| 
 | ||||
|   public async getBlocks(req: Request, res: Response) { | ||||
|     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); | ||||
|         res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|         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__", | ||||
|     "STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__", | ||||
|     "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": { | ||||
|     "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_USER_AGENT__=${MEMPOOL_USER_AGENT:=mempool} | ||||
| __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_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_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_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_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json | ||||
|  | ||||
| @ -121,20 +121,20 @@ describe('Mainnet', () => { | ||||
|         cy.visit('/'); | ||||
|         cy.get('.search-box-container > .form-control').type('1wiz').then(() => { | ||||
|           cy.wait('@search-1wiz'); | ||||
|           cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 10); | ||||
|           cy.get('app-search-results button.dropdown-item').should('have.length', 10); | ||||
|         }); | ||||
| 
 | ||||
|         cy.get('.search-box-container > .form-control').type('S').then(() => { | ||||
|           cy.wait('@search-1wizS'); | ||||
|           cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 5); | ||||
|           cy.get('app-search-results button.dropdown-item').should('have.length', 5); | ||||
|         }); | ||||
| 
 | ||||
|         cy.get('.search-box-container > .form-control').type('A').then(() => { | ||||
|           cy.wait('@search-1wizSA'); | ||||
|           cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1) | ||||
|           cy.get('app-search-results button.dropdown-item').should('have.length', 1) | ||||
|         }); | ||||
| 
 | ||||
|         cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { | ||||
|         cy.get('app-search-results button.dropdown-item.active').click().then(() => { | ||||
|           cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC'); | ||||
|           cy.waitForSkeletonGone(); | ||||
|           cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); | ||||
| @ -145,8 +145,8 @@ describe('Mainnet', () => { | ||||
|         it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => { | ||||
|           cy.visit('/'); | ||||
|           cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { | ||||
|             cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1); | ||||
|             cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { | ||||
|             cy.get('app-search-results button.dropdown-item').should('have.length', 1); | ||||
|             cy.get('app-search-results button.dropdown-item.active').click().then(() => { | ||||
|               cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e'); | ||||
|               cy.waitForSkeletonGone(); | ||||
|               cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); | ||||
| @ -159,8 +159,8 @@ describe('Mainnet', () => { | ||||
|         it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => { | ||||
|           cy.visit('/'); | ||||
|           cy.get('.search-box-container > .form-control').type(searchTerm).then(() => { | ||||
|             cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1); | ||||
|             cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => { | ||||
|             cy.get('app-search-results button.dropdown-item').should('have.length', 1); | ||||
|             cy.get('app-search-results button.dropdown-item.active').click().then(() => { | ||||
|               cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy'); | ||||
|               cy.waitForSkeletonGone(); | ||||
|               cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address'); | ||||
|  | ||||
| @ -16,5 +16,6 @@ | ||||
|   "MEMPOOL_WEBSITE_URL": "https://mempool.space", | ||||
|   "LIQUID_WEBSITE_URL": "https://liquid.network", | ||||
|   "BISQ_WEBSITE_URL": "https://bisq.markets", | ||||
|   "MINING_DASHBOARD": true | ||||
|   "MINING_DASHBOARD": true, | ||||
|   "LIGHTNING": false | ||||
| } | ||||
|  | ||||
							
								
								
									
										4
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "mempool-frontend", | ||||
|   "version": "2.4.1-dev", | ||||
|   "version": "2.5.0-dev", | ||||
|   "lockfileVersion": 2, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "mempool-frontend", | ||||
|       "version": "2.4.1-dev", | ||||
|       "version": "2.5.0-dev", | ||||
|       "license": "GNU Affero General Public License v3.0", | ||||
|       "dependencies": { | ||||
|         "@angular-devkit/build-angular": "~13.3.7", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "mempool-frontend", | ||||
|   "version": "2.4.1-dev", | ||||
|   "version": "2.5.0-dev", | ||||
|   "description": "Bitcoin mempool visualizer and blockchain explorer backend", | ||||
|   "license": "GNU Affero General Public License v3.0", | ||||
|   "homepage": "https://mempool.space", | ||||
|  | ||||
| @ -102,6 +102,16 @@ if (configContent && configContent.BASE_MODULE === 'bisq') { | ||||
| } | ||||
| 
 | ||||
| PROXY_CONFIG.push(...[ | ||||
|   { | ||||
|     context: ['/testnet/api/v1/lightning/**'], | ||||
|     target: `http://localhost:8999`, | ||||
|     secure: false, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|     pathRewrite: { | ||||
|         "^/testnet": "" | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/**'], | ||||
|     target: `http://localhost:8999`, | ||||
|  | ||||
| @ -96,6 +96,10 @@ let routes: Routes = [ | ||||
|             path: 'api', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
|           }, | ||||
|           { | ||||
|             path: 'lightning', | ||||
|             loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
| @ -186,6 +190,10 @@ let routes: Routes = [ | ||||
|             path: 'api', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
|           }, | ||||
|           { | ||||
|             path: 'lightning', | ||||
|             loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
| @ -273,6 +281,10 @@ let routes: Routes = [ | ||||
|         path: 'api', | ||||
|         loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
|       }, | ||||
|       { | ||||
|         path: 'lightning', | ||||
|         loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|  | ||||
| @ -1,4 +1,13 @@ | ||||
| <a *ngIf="channel; else default" [routerLink]="['/lightning/channel' | relativeUrl, channel.id]"> | ||||
|   <span | ||||
|     *ngIf="label" | ||||
|     class="badge badge-pill badge-warning" | ||||
|   >{{ label }}</span> | ||||
| </a> | ||||
| 
 | ||||
| <ng-template #default> | ||||
|   <span | ||||
|     *ngIf="label" | ||||
|     class="badge badge-pill badge-warning" | ||||
|   >{{ label }}</span> | ||||
| </ng-template> | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; | ||||
| import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core'; | ||||
| import { Vin, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| 
 | ||||
| @ -8,11 +8,12 @@ import { StateService } from 'src/app/services/state.service'; | ||||
|   styleUrls: ['./address-labels.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class AddressLabelsComponent implements OnInit { | ||||
| export class AddressLabelsComponent implements OnChanges { | ||||
|   network = ''; | ||||
| 
 | ||||
|   @Input() vin: Vin; | ||||
|   @Input() vout: Vout; | ||||
|   @Input() channel: any; | ||||
| 
 | ||||
|   label?: string; | ||||
| 
 | ||||
| @ -22,14 +23,21 @@ export class AddressLabelsComponent implements OnInit { | ||||
|     this.network = stateService.network; | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     if (this.vin) { | ||||
|   ngOnChanges() { | ||||
|     if (this.channel) { | ||||
|       this.handleChannel(); | ||||
|     } else if (this.vin) { | ||||
|       this.handleVin(); | ||||
|     } else if (this.vout) { | ||||
|       this.handleVout(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleChannel() { | ||||
|     const type = this.vout ? 'open' : 'close'; | ||||
|     this.label = `Channel ${type}: ${this.channel.node_left.alias} <> ${this.channel.node_right.alias}`; | ||||
|   } | ||||
| 
 | ||||
|   handleVin() { | ||||
|     if (this.vin.inner_witnessscript_asm) { | ||||
|       if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) { | ||||
|  | ||||
| @ -55,10 +55,7 @@ | ||||
|               <tr> | ||||
|                 <td i18n="block.timestamp">Timestamp</td> | ||||
|                 <td> | ||||
|                   ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||
|                   <div class="lg-inline"> | ||||
|                     <i class="symbol">(<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>)</i> | ||||
|                   </div> | ||||
|                   <app-timestamp [unixTime]="block.timestamp"></app-timestamp> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|  | ||||
							
								
								
									
										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;"> | ||||
|   <button #btn class="btn btn-sm btn-link pt-0" style="line-height: 0.9;" [attr.data-clipboard-text]="text">  | ||||
|     <img src="./resources/clippy.svg" width="13"> | ||||
|   <button #btn class="btn btn-sm btn-link pt-0" [style]="{'line-height': size === 'small' ? '0.2' : '0.8'}" [attr.data-clipboard-text]="text">  | ||||
|     <img src="./resources/clippy.svg" [width]="size === 'small' ? 10 : 13"> | ||||
|   </button> | ||||
| </span> | ||||
|  | ||||
| @ -1,3 +1,8 @@ | ||||
| .btn-link { | ||||
|   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 { | ||||
|   @ViewChild('btn') btn: ElementRef; | ||||
|   @ViewChild('buttonWrapper') buttonWrapper: ElementRef; | ||||
|   @Input() size: 'small' | 'normal' = 'normal'; | ||||
|   @Input() text: string; | ||||
|   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 { formatNumber } from '@angular/common'; | ||||
| import { selectPowerOfTen } from 'src/app/bitcoin.utils'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-difficulty-adjustments-table', | ||||
| @ -26,10 +27,16 @@ export class DifficultyAdjustmentsTable implements OnInit { | ||||
|   constructor( | ||||
|     @Inject(LOCALE_ID) public locale: string, | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     let decimals = 2; | ||||
|     if (this.stateService.network === 'signet') { | ||||
|       decimals = 5; | ||||
|     } | ||||
| 
 | ||||
|     this.hashrateObservable$ = this.apiService.getDifficultyAdjustments$('3m') | ||||
|       .pipe( | ||||
|         map((response) => { | ||||
| @ -43,7 +50,7 @@ export class DifficultyAdjustmentsTable implements OnInit { | ||||
|               change: (adjustment[3] - 1) * 100, | ||||
|               difficultyShorten: formatNumber( | ||||
|                 adjustment[2] / selectedPowerOfTen.divider, | ||||
|                 this.locale, '1.2-2') + selectedPowerOfTen.unit | ||||
|                 this.locale, `1.${decimals}-${decimals}`) + selectedPowerOfTen.unit | ||||
|             }); | ||||
|           } | ||||
|           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" | ||||
|     [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> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownBasic1"> | ||||
|       <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]" | ||||
|         i18n="mining.pools-dominance">Pools Dominance</a> | ||||
|       <a class="dropdown-item" routerLinkActive="active" | ||||
|         [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate & Difficulty</a> | ||||
|       <a class="dropdown-item" routerLinkActive="active" | ||||
|         [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-fees' | relativeUrl]" i18n="mining.block-fees">Block Fees</a> | ||||
|       <a class="dropdown-item" routerLinkActive="active" | ||||
|         [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" i18n="mining.block-rewards">Block Rewards</a> | ||||
|         [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">Hashrate & | ||||
|         Difficulty</a> | ||||
|       <a class="dropdown-item" routerLinkActive="active" [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-fees' | relativeUrl]" | ||||
|         i18n="mining.block-fees">Block Fees</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" | ||||
|         [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" i18n="mining.block-sizes-weights">Block Sizes and Weights</a> | ||||
|       <a class="dropdown-item" routerLinkActive="active" | ||||
|         [routerLink]="['/graphs/mining/block-prediction' | relativeUrl]" i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</a> | ||||
|     </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> | ||||
| 
 | ||||
| <router-outlet></router-outlet> | ||||
|  | ||||
| @ -12,8 +12,11 @@ | ||||
|       </div> | ||||
|       <div class="item"> | ||||
|         <h5 class="card-title" i18n="block.difficulty">Difficulty</h5> | ||||
|         <p class="card-text"> | ||||
|           {{ hashrates.currentDifficulty | amountShortener }} | ||||
|         <p class="card-text" *ngIf="network === 'signet'"> | ||||
|           {{ hashrates.currentDifficulty | amountShortener : 5 }} | ||||
|         </p> | ||||
|         <p class="card-text" *ngIf="network !== 'signet'"> | ||||
|           {{ hashrates.currentDifficulty | amountShortener : 2 }} | ||||
|         </p> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
| @ -335,6 +335,9 @@ export class HashrateChartComponent implements OnInit { | ||||
|           axisLabel: { | ||||
|             color: 'rgb(110, 112, 121)', | ||||
|             formatter: (val) => { | ||||
|               if (this.stateService.network === 'signet') { | ||||
|                 return val; | ||||
|               } | ||||
|               const selectedPowerOfTen: any = selectPowerOfTen(val); | ||||
|               const newVal = Math.round(val / selectedPowerOfTen.divider); | ||||
|               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"> | ||||
|         <a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.LIGHTNING"> | ||||
|         <a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD"> | ||||
|         <a class="nav-link" [routerLink]="['/blocks' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'cubes']" [fixedWidth]="true" i18n-title="master-page.blocks" title="Blocks"></fa-icon></a> | ||||
|       </li> | ||||
|  | ||||
| @ -1,11 +1,12 @@ | ||||
| import { Component, Input, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; | ||||
| import { Component, Input, AfterViewInit, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core'; | ||||
| import * as QRCode from 'qrcode'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-qrcode', | ||||
|   templateUrl: './qrcode.component.html', | ||||
|   styleUrls: ['./qrcode.component.scss'] | ||||
|   styleUrls: ['./qrcode.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class QrcodeComponent implements AfterViewInit { | ||||
|   @Input() data: string; | ||||
| @ -19,7 +20,18 @@ export class QrcodeComponent implements AfterViewInit { | ||||
|     private stateService: StateService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnChanges() { | ||||
|     if (!this.canvas || !this.canvas.nativeElement) { | ||||
|       return; | ||||
|     } | ||||
|     this.render(); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewInit() { | ||||
|     this.render(); | ||||
|   } | ||||
| 
 | ||||
|   render() { | ||||
|     if (!this.stateService.isBrowser) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @ -1,7 +1,10 @@ | ||||
| <form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate> | ||||
|   <div class="d-flex"> | ||||
|     <div class="search-box-container mr-2"> | ||||
|       <input #instance="ngbTypeahead" [ngbTypeahead]="typeaheadSearchFn" [resultFormatter]="formatterFn" (selectItem)="itemSelected()" (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address"> | ||||
|       <input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="TXID, block height, hash or address"> | ||||
|        | ||||
|       <app-search-results #searchResults [results]="typeAhead$ | async" [searchTerm]="searchForm.get('searchText').value" (selectedResult)="selectedResult($event)"></app-search-results> | ||||
|      | ||||
|     </div> | ||||
|     <div> | ||||
|       <button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"><fa-icon [icon]="['fas', 'search']" [fixedWidth]="true" i18n-title="search-form.search-title" title="Search"></fa-icon></button> | ||||
|  | ||||
| @ -32,6 +32,7 @@ form { | ||||
| } | ||||
| 
 | ||||
| .search-box-container { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   @media (min-width: 768px) { | ||||
|     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 { Router } from '@angular/router'; | ||||
| import { AssetsService } from 'src/app/services/assets.service'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| import { Observable, of, Subject, merge } from 'rxjs'; | ||||
| import { Observable, of, Subject, merge, zip } from 'rxjs'; | ||||
| import { debounceTime, distinctUntilChanged, switchMap, filter, catchError, map } from 'rxjs/operators'; | ||||
| import { ElectrsApiService } from 'src/app/services/electrs-api.service'; | ||||
| import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { ShortenStringPipe } from 'src/app/shared/pipes/shorten-string-pipe/shorten-string.pipe'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SearchResultsComponent } from './search-results/search-results.component'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-search-form', | ||||
|   templateUrl: './search-form.component.html', | ||||
|   styleUrls: ['./search-form.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class SearchFormComponent implements OnInit { | ||||
|   network = ''; | ||||
|   assets: object = {}; | ||||
|   isSearching = false; | ||||
|   typeaheadSearchFn: ((text: Observable<string>) => Observable<readonly any[]>); | ||||
| 
 | ||||
|   typeAhead$: Observable<any>; | ||||
|   searchForm: FormGroup; | ||||
|   isMobile = (window.innerWidth <= 767.98); | ||||
|   @Output() searchTriggered = new EventEmitter(); | ||||
| 
 | ||||
|   regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/; | ||||
|   regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; | ||||
|   regexTransaction = /^([a-fA-F0-9]{64}):?(\d+)?$/; | ||||
|   regexBlockheight = /^[0-9]+$/; | ||||
| 
 | ||||
|   @ViewChild('instance', {static: true}) instance: NgbTypeahead; | ||||
|   focus$ = new Subject<string>(); | ||||
|   click$ = new Subject<string>(); | ||||
| 
 | ||||
|   formatterFn = (address: string) => this.shortenStringPipe.transform(address, this.isMobile ? 33 : 40); | ||||
|   @Output() searchTriggered = new EventEmitter(); | ||||
|   @ViewChild('searchResults') searchResults: SearchResultsComponent; | ||||
|   @HostListener('keydown', ['$event']) keydown($event) { | ||||
|     this.handleKeyDown($event); | ||||
|   } | ||||
| 
 | ||||
|   constructor( | ||||
|     private formBuilder: FormBuilder, | ||||
| @ -43,12 +42,11 @@ export class SearchFormComponent implements OnInit { | ||||
|     private assetsService: AssetsService, | ||||
|     private stateService: StateService, | ||||
|     private electrsApiService: ElectrsApiService, | ||||
|     private apiService: ApiService, | ||||
|     private relativeUrlPipe: RelativeUrlPipe, | ||||
|     private shortenStringPipe: ShortenStringPipe, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     this.typeaheadSearchFn = this.typeaheadSearch; | ||||
|     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||
| 
 | ||||
|     this.searchForm = this.formBuilder.group({ | ||||
| @ -61,45 +59,74 @@ export class SearchFormComponent implements OnInit { | ||||
|           this.assets = assets; | ||||
|         }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   typeaheadSearch = (text$: Observable<string>) => { | ||||
|     const debouncedText$ = text$.pipe( | ||||
|     this.typeAhead$ = this.searchForm.get('searchText').valueChanges | ||||
|       .pipe( | ||||
|         map((text) => { | ||||
|           if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) { | ||||
|             return text.substr(1); | ||||
|           } | ||||
|         return text; | ||||
|           return text.trim(); | ||||
|         }), | ||||
|       debounceTime(200), | ||||
|       distinctUntilChanged() | ||||
|     ); | ||||
|     const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); | ||||
|     const inputFocus$ = this.focus$; | ||||
| 
 | ||||
|     return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$) | ||||
|       .pipe( | ||||
|         debounceTime(250), | ||||
|         distinctUntilChanged(), | ||||
|         switchMap((text) => { | ||||
|           if (!text.length) { | ||||
|             return of([]); | ||||
|             return of([ | ||||
|               [], | ||||
|               { | ||||
|                 nodes: [], | ||||
|                 channels: [], | ||||
|               } | ||||
|           return this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))); | ||||
|             ]); | ||||
|           } | ||||
|           if (!this.stateService.env.LIGHTNING) { | ||||
|             return zip( | ||||
|               this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), | ||||
|               [{ nodes: [], channels: [] }] | ||||
|             ); | ||||
|           } | ||||
|           return zip( | ||||
|             this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), | ||||
|             this.apiService.lightningSearch$(text).pipe(catchError(() => of({ | ||||
|               nodes: [], | ||||
|               channels: [], | ||||
|             }))), | ||||
|           ); | ||||
|         }), | ||||
|         map((result: string[]) => { | ||||
|         map((result: any[]) => { | ||||
|           if (this.network === 'bisq') { | ||||
|             return result.map((address: string) => 'B' + address); | ||||
|             return result[0].map((address: string) => 'B' + address); | ||||
|           } | ||||
|           return result; | ||||
|           return { | ||||
|             addresses: result[0], | ||||
|             nodes: result[1].nodes, | ||||
|             channels: result[1].channels, | ||||
|             totalResults: result[0].length + result[1].nodes.length + result[1].channels.length, | ||||
|           }; | ||||
|         }) | ||||
|       ); | ||||
|   } | ||||
|   handleKeyDown($event) { | ||||
|     this.searchResults.handleKeyDown($event); | ||||
|   } | ||||
| 
 | ||||
|   itemSelected() { | ||||
|     setTimeout(() => this.search()); | ||||
|   } | ||||
| 
 | ||||
|   search() { | ||||
|     const searchText = this.searchForm.value.searchText.trim(); | ||||
|   selectedResult(result: any) { | ||||
|     if (typeof result === 'string') { | ||||
|       this.search(result); | ||||
|     } else if (result.alias) { | ||||
|       this.navigate('/lightning/node/', result.public_key); | ||||
|     } else if (result.short_id) { | ||||
|       this.navigate('/lightning/channel/', result.id); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   search(result?: string) { | ||||
|     const searchText = result || this.searchForm.value.searchText.trim(); | ||||
|     if (searchText) { | ||||
|       this.isSearching = true; | ||||
|       if (this.regexAddress.test(searchText)) { | ||||
|  | ||||
| @ -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 = {}; | ||||
| 
 | ||||
|   @Input() time: number; | ||||
|   @Input() dateString: number; | ||||
|   @Input() fastRender = false; | ||||
| 
 | ||||
|   constructor( | ||||
| @ -52,7 +53,13 @@ export class TimeSinceComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   } | ||||
| 
 | ||||
|   calculate() { | ||||
|     const seconds = Math.floor((+new Date() - +new Date(this.time * 1000)) / 1000); | ||||
|     let date: Date; | ||||
|     if (this.dateString) { | ||||
|       date = new Date(this.dateString) | ||||
|     } else { | ||||
|       date = new Date(this.time * 1000); | ||||
|     } | ||||
|     const seconds = Math.floor((+new Date() - +date) / 1000); | ||||
|     if (seconds < 60) { | ||||
|       return $localize`:@@date-base.just-now:Just now`; | ||||
|     } | ||||
|  | ||||
| @ -77,7 +77,7 @@ | ||||
|                           {{ vin.prevout.scriptpubkey_type?.toUpperCase() }} | ||||
|                         </ng-template> | ||||
|                         <div> | ||||
|                           <app-address-labels [vin]="vin"></app-address-labels> | ||||
|                           <app-address-labels [vin]="vin" [channel]="channels && channels.inputs[i] || null"></app-address-labels> | ||||
|                         </div> | ||||
|                       </ng-template> | ||||
|                     </ng-container> | ||||
| @ -172,7 +172,7 @@ | ||||
|                     </span> | ||||
|                   </a> | ||||
|                   <div> | ||||
|                     <app-address-labels [vout]="vout"></app-address-labels> | ||||
|                     <app-address-labels [vout]="vout" [channel]="channels && channels.outputs[i] && channels.outputs[i].transaction_vout === vindex ? channels.outputs[i] : null"></app-address-labels> | ||||
|                   </div> | ||||
|                   <ng-template #scriptpubkey_type> | ||||
|                     <ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type"> | ||||
|  | ||||
| @ -1,11 +1,11 @@ | ||||
| import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Observable, forkJoin, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; | ||||
| import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; | ||||
| import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { ElectrsApiService } from '../../services/electrs-api.service'; | ||||
| import { environment } from 'src/environments/environment'; | ||||
| import { AssetsService } from 'src/app/services/assets.service'; | ||||
| import { map, tap, switchMap } from 'rxjs/operators'; | ||||
| import { filter, map, tap, switchMap } from 'rxjs/operators'; | ||||
| import { BlockExtended } from 'src/app/interfaces/node-api.interface'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| 
 | ||||
| @ -32,9 +32,11 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|   latestBlock$: Observable<BlockExtended>; | ||||
|   outspendsSubscription: Subscription; | ||||
|   refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject(); | ||||
|   refreshChannels$: ReplaySubject<string[]> = new ReplaySubject(); | ||||
|   showDetails$ = new BehaviorSubject<boolean>(false); | ||||
|   outspends: Outspend[][] = []; | ||||
|   assetsMinimal: any; | ||||
|   channels: { inputs: any[], outputs: any[] }; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
| @ -73,7 +75,16 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|               }; | ||||
|             } | ||||
|           }), | ||||
|         ), | ||||
|         this.refreshChannels$ | ||||
|           .pipe( | ||||
|             filter(() => this.stateService.env.LIGHTNING), | ||||
|             switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)), | ||||
|             map((channels) => { | ||||
|               this.channels = channels; | ||||
|             }), | ||||
|           ) | ||||
|         , | ||||
|     ).subscribe(() => this.ref.markForCheck()); | ||||
|   } | ||||
| 
 | ||||
| @ -114,8 +125,9 @@ export class TransactionsListComponent implements OnInit, OnChanges { | ||||
|         tx['addressValue'] = addressIn - addressOut; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.refreshOutspends$.next(this.transactions.map((tx) => tx.txid)); | ||||
|     const txIds = this.transactions.map((tx) => tx.txid); | ||||
|     this.refreshOutspends$.next(txIds); | ||||
|     this.refreshChannels$.next(txIds); | ||||
|   } | ||||
| 
 | ||||
|   onScroll() { | ||||
|  | ||||
| @ -57,6 +57,9 @@ import { CommonModule } from '@angular/common'; | ||||
|     NgxEchartsModule.forRoot({ | ||||
|       echarts: () => import('echarts') | ||||
|     }) | ||||
|   ], | ||||
|   exports: [ | ||||
|     NgxEchartsModule, | ||||
|   ] | ||||
| }) | ||||
| export class GraphsModule { } | ||||
|  | ||||
| @ -18,6 +18,8 @@ import { StartComponent } from '../components/start/start.component'; | ||||
| import { StatisticsComponent } from '../components/statistics/statistics.component'; | ||||
| import { TelevisionComponent } from '../components/television/television.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 || {}; | ||||
| // @ts-ignore
 | ||||
| @ -89,6 +91,14 @@ const routes: Routes = [ | ||||
|             path: 'mining/block-sizes-weights', | ||||
|             component: BlockSizesWeightsGraphComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'lightning/nodes-networks', | ||||
|             component: NodesNetworksChartComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: 'lightning/capacity', | ||||
|             component: LightningStatisticsChartComponent, | ||||
|           }, | ||||
|           { | ||||
|             path: '', | ||||
|             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