diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index a9266a016..7c7608aff 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 63; + private static currentVersion = 64; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -543,6 +543,11 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"'); await this.updateToSchemaVersion(63); } + + if (databaseSchemaVersion < 64 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL'); + await this.updateToSchemaVersion(64); + } } /** diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index d429299e1..22f9ca48a 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -3,6 +3,7 @@ import DB from '../../database'; import { ResultSetHeader } from 'mysql2'; import { ILightningApi } from '../lightning/lightning-api.interface'; import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces'; +import { bin2hex } from '../../utils/format'; class NodesApi { public async $getWorldNodes(): Promise { @@ -56,7 +57,8 @@ class NodesApi { UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets, as_number, city_id, country_id, subdivision_id, longitude, latitude, geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city, - geo_names_country.names as country, geo_names_subdivision.names as subdivision + geo_names_country.names as country, geo_names_subdivision.names as subdivision, + features FROM nodes LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id @@ -76,6 +78,23 @@ class NodesApi { node.city = JSON.parse(node.city); node.country = JSON.parse(node.country); + // Features + node.features = JSON.parse(node.features); + node.featuresBits = null; + if (node.features) { + let maxBit = 0; + for (const feature of node.features) { + maxBit = Math.max(maxBit, feature.bit); + } + maxBit = Math.ceil(maxBit / 4) * 4 - 1; + + node.featuresBits = new Array(maxBit + 1).fill(0); + for (const feature of node.features) { + node.featuresBits[feature.bit] = 1; + } + node.featuresBits = bin2hex(node.featuresBits.reverse().join('')); + } + // Active channels and capacity const activeChannelsStats: any = await this.$getActiveChannelsStats(public_key); node.active_channel_count = activeChannelsStats.active_channel_count ?? 0; @@ -656,10 +675,19 @@ class NodesApi { alias_search, color, sockets, - status + status, + features ) - VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1) - ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`; + VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE + updated_at = FROM_UNIXTIME(?), + alias = ?, + alias_search = ?, + color = ?, + sockets = ?, + status = 1, + features = ? + `; await DB.query(query, [ node.pub_key, @@ -668,11 +696,13 @@ class NodesApi { this.aliasToSearchText(node.alias), node.color, sockets, + JSON.stringify(node.features), node.last_update, node.alias, this.aliasToSearchText(node.alias), node.color, sockets, + JSON.stringify(node.features), ]); } catch (e) { logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e)); diff --git a/backend/src/api/lightning/clightning/clightning-convert.ts b/backend/src/api/lightning/clightning/clightning-convert.ts index 084965383..771dabcd7 100644 --- a/backend/src/api/lightning/clightning/clightning-convert.ts +++ b/backend/src/api/lightning/clightning/clightning-convert.ts @@ -2,8 +2,91 @@ import { ILightningApi } from '../lightning-api.interface'; import FundingTxFetcher from '../../../tasks/lightning/sync-tasks/funding-tx-fetcher'; import logger from '../../../logger'; import { Common } from '../../common'; +import { hex2bin } from '../../../utils/format'; import config from '../../../config'; +// https://github.com/lightningnetwork/lnd/blob/master/lnwire/features.go +export enum FeatureBits { + DataLossProtectRequired = 0, + DataLossProtectOptional = 1, + InitialRoutingSync = 3, + UpfrontShutdownScriptRequired = 4, + UpfrontShutdownScriptOptional = 5, + GossipQueriesRequired = 6, + GossipQueriesOptional = 7, + TLVOnionPayloadRequired = 8, + TLVOnionPayloadOptional = 9, + StaticRemoteKeyRequired = 12, + StaticRemoteKeyOptional = 13, + PaymentAddrRequired = 14, + PaymentAddrOptional = 15, + MPPRequired = 16, + MPPOptional = 17, + WumboChannelsRequired = 18, + WumboChannelsOptional = 19, + AnchorsRequired = 20, + AnchorsOptional = 21, + AnchorsZeroFeeHtlcTxRequired = 22, + AnchorsZeroFeeHtlcTxOptional = 23, + ShutdownAnySegwitRequired = 26, + ShutdownAnySegwitOptional = 27, + AMPRequired = 30, + AMPOptional = 31, + ExplicitChannelTypeRequired = 44, + ExplicitChannelTypeOptional = 45, + ScidAliasRequired = 46, + ScidAliasOptional = 47, + PaymentMetadataRequired = 48, + PaymentMetadataOptional = 49, + ZeroConfRequired = 50, + ZeroConfOptional = 51, + KeysendRequired = 54, + KeysendOptional = 55, + ScriptEnforcedLeaseRequired = 2022, + ScriptEnforcedLeaseOptional = 2023, + MaxBolt11Feature = 5114, +}; + +export const FeaturesMap = new Map([ + [FeatureBits.DataLossProtectRequired, 'data-loss-protect'], + [FeatureBits.DataLossProtectOptional, 'data-loss-protect'], + [FeatureBits.InitialRoutingSync, 'initial-routing-sync'], + [FeatureBits.UpfrontShutdownScriptRequired, 'upfront-shutdown-script'], + [FeatureBits.UpfrontShutdownScriptOptional, 'upfront-shutdown-script'], + [FeatureBits.GossipQueriesRequired, 'gossip-queries'], + [FeatureBits.GossipQueriesOptional, 'gossip-queries'], + [FeatureBits.TLVOnionPayloadRequired, 'tlv-onion'], + [FeatureBits.TLVOnionPayloadOptional, 'tlv-onion'], + [FeatureBits.StaticRemoteKeyOptional, 'static-remote-key'], + [FeatureBits.StaticRemoteKeyRequired, 'static-remote-key'], + [FeatureBits.PaymentAddrOptional, 'payment-addr'], + [FeatureBits.PaymentAddrRequired, 'payment-addr'], + [FeatureBits.MPPOptional, 'multi-path-payments'], + [FeatureBits.MPPRequired, 'multi-path-payments'], + [FeatureBits.AnchorsRequired, 'anchor-commitments'], + [FeatureBits.AnchorsOptional, 'anchor-commitments'], + [FeatureBits.AnchorsZeroFeeHtlcTxRequired, 'anchors-zero-fee-htlc-tx'], + [FeatureBits.AnchorsZeroFeeHtlcTxOptional, 'anchors-zero-fee-htlc-tx'], + [FeatureBits.WumboChannelsRequired, 'wumbo-channels'], + [FeatureBits.WumboChannelsOptional, 'wumbo-channels'], + [FeatureBits.AMPRequired, 'amp'], + [FeatureBits.AMPOptional, 'amp'], + [FeatureBits.PaymentMetadataOptional, 'payment-metadata'], + [FeatureBits.PaymentMetadataRequired, 'payment-metadata'], + [FeatureBits.ExplicitChannelTypeOptional, 'explicit-commitment-type'], + [FeatureBits.ExplicitChannelTypeRequired, 'explicit-commitment-type'], + [FeatureBits.KeysendOptional, 'keysend'], + [FeatureBits.KeysendRequired, 'keysend'], + [FeatureBits.ScriptEnforcedLeaseRequired, 'script-enforced-lease'], + [FeatureBits.ScriptEnforcedLeaseOptional, 'script-enforced-lease'], + [FeatureBits.ScidAliasRequired, 'scid-alias'], + [FeatureBits.ScidAliasOptional, 'scid-alias'], + [FeatureBits.ZeroConfRequired, 'zero-conf'], + [FeatureBits.ZeroConfOptional, 'zero-conf'], + [FeatureBits.ShutdownAnySegwitRequired, 'shutdown-any-segwit'], + [FeatureBits.ShutdownAnySegwitOptional, 'shutdown-any-segwit'], +]); + /** * Convert a clightning "listnode" entry to a lnd node entry */ @@ -17,10 +100,36 @@ export function convertNode(clNode: any): ILightningApi.Node { custom_records = undefined; } } + + const nodeFeatures: ILightningApi.Feature[] = []; + const nodeFeaturesBinary = hex2bin(clNode.features).split('').reverse().join(''); + + for (let i = 0; i < nodeFeaturesBinary.length; i++) { + if (nodeFeaturesBinary[i] === '0') { + continue; + } + const feature = FeaturesMap.get(i); + if (!feature) { + nodeFeatures.push({ + bit: i, + name: 'unknown', + is_required: i % 2 === 0, + is_known: false + }); + } else { + nodeFeatures.push({ + bit: i, + name: feature, + is_required: i % 2 === 0, + is_known: true + }); + } + } + return { alias: clNode.alias ?? '', color: `#${clNode.color ?? ''}`, - features: [], // TODO parse and return clNode.feature + features: nodeFeatures, pub_key: clNode.nodeid, addresses: clNode.addresses?.map((addr) => { let address = addr.address; diff --git a/backend/src/api/lightning/lightning-api.interface.ts b/backend/src/api/lightning/lightning-api.interface.ts index cd5cb973d..ef26646a0 100644 --- a/backend/src/api/lightning/lightning-api.interface.ts +++ b/backend/src/api/lightning/lightning-api.interface.ts @@ -79,6 +79,7 @@ export namespace ILightningApi { } export interface Feature { + bit: number; name: string; is_required: boolean; is_known: boolean; diff --git a/backend/src/api/lightning/lnd/lnd-api.ts b/backend/src/api/lightning/lnd/lnd-api.ts index b4c91d36e..f4099e82b 100644 --- a/backend/src/api/lightning/lnd/lnd-api.ts +++ b/backend/src/api/lightning/lnd/lnd-api.ts @@ -41,8 +41,23 @@ class LndApi implements AbstractLightningApi { } async $getNetworkGraph(): Promise { - return axios.get(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig) + const graph = await axios.get(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig) .then((response) => response.data); + + for (const node of graph.nodes) { + const nodeFeatures: ILightningApi.Feature[] = []; + for (const bit in node.features) { + nodeFeatures.push({ + bit: parseInt(bit, 10), + name: node.features[bit].name, + is_required: node.features[bit].is_required, + is_known: node.features[bit].is_known, + }); + } + node.features = nodeFeatures; + } + + return graph; } } diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 6785b0e2d..963b9e8c2 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -3,7 +3,6 @@ import logger from '../../logger'; import channelsApi from '../../api/explorer/channels.api'; import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory'; import config from '../../config'; -import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface'; import { ILightningApi } from '../../api/lightning/lightning-api.interface'; import { $lookupNodeLocation } from './sync-tasks/node-locations'; import lightningApi from '../../api/lightning/lightning-api-factory'; diff --git a/backend/src/utils/format.ts b/backend/src/utils/format.ts index a18ce1892..63dc07ae4 100644 --- a/backend/src/utils/format.ts +++ b/backend/src/utils/format.ts @@ -26,4 +26,70 @@ export function formatBytes(bytes: number, toUnit: string, skipUnit = false): st } return `${bytes.toFixed(2)}${skipUnit ? '' : ' ' + byteUnits[unitIndex]}`; +} + +// https://stackoverflow.com/a/64235212 +export function hex2bin(hex: string): string { + if (!hex) { + return ''; + } + + hex = hex.replace('0x', '').toLowerCase(); + let out = ''; + + for (const c of hex) { + switch (c) { + case '0': out += '0000'; break; + case '1': out += '0001'; break; + case '2': out += '0010'; break; + case '3': out += '0011'; break; + case '4': out += '0100'; break; + case '5': out += '0101'; break; + case '6': out += '0110'; break; + case '7': out += '0111'; break; + case '8': out += '1000'; break; + case '9': out += '1001'; break; + case 'a': out += '1010'; break; + case 'b': out += '1011'; break; + case 'c': out += '1100'; break; + case 'd': out += '1101'; break; + case 'e': out += '1110'; break; + case 'f': out += '1111'; break; + default: return ''; + } + } + return out; +} + +export function bin2hex(bin: string): string { + if (!bin) { + return ''; + } + + let out = ''; + + for (let i = 0; i < bin.length; i += 4) { + const c = bin.substring(i, i + 4); + switch (c) { + case '0000': out += '0'; break; + case '0001': out += '1'; break; + case '0010': out += '2'; break; + case '0011': out += '3'; break; + case '0100': out += '4'; break; + case '0101': out += '5'; break; + case '0110': out += '6'; break; + case '0111': out += '7'; break; + case '1000': out += '8'; break; + case '1001': out += '9'; break; + case '1010': out += 'a'; break; + case '1011': out += 'b'; break; + case '1100': out += 'c'; break; + case '1101': out += 'd'; break; + case '1110': out += 'e'; break; + case '1111': out += 'f'; break; + default: return ''; + } + } + + return out; } \ No newline at end of file diff --git a/frontend/src/app/lightning/node/node.component.html b/frontend/src/app/lightning/node/node.component.html index 2a74a68aa..c6c693a3a 100644 --- a/frontend/src/app/lightning/node/node.component.html +++ b/frontend/src/app/lightning/node/node.component.html @@ -21,7 +21,6 @@
-
@@ -59,6 +58,9 @@ + + +
Avg channel distance {{ avgDistance | amountShortener: 1 }} km ยท{{ kmToMiles(avgDistance) | amountShortener: 1 }} mi
@@ -100,11 +102,50 @@ + + + + + +
+ + + Features + + {{ bits }} + + + + +
+
+
+
+
Raw bits
+ {{ node.featuresBits }} +
+
Decoded
+ + + + + + + + + + + + + +
BitNameRequired
{{ feature.bit }}{{ feature.name }}{{ feature.is_required }}
+
+
diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts index 47f65007f..719136d79 100644 --- a/frontend/src/app/lightning/node/node.component.ts +++ b/frontend/src/app/lightning/node/node.component.ts @@ -37,7 +37,7 @@ export class NodeComponent implements OnInit { liquidityAd: ILiquidityAd; tlvRecords: CustomRecord[]; avgChannelDistance$: Observable; - + showFeatures = false; kmToMiles = kmToMiles; constructor( @@ -164,4 +164,9 @@ export class NodeComponent implements OnInit { onLoadingEvent(e) { this.channelListLoading = e; } + + toggleFeatures() { + this.showFeatures = !this.showFeatures; + return false; + } } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index ac299a547..428752d60 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1164,3 +1164,10 @@ app-master-page, app-liquid-master-page, app-bisq-master-page { app-global-footer { margin-top: auto; } + +.btn-xs { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 0.5; + border-radius: 0.2rem; +}