diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 20e5ab339..3bbd501fc 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 42; + private static currentVersion = 43; private queryTimeout = 120000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -356,6 +356,10 @@ class DatabaseMigration { if (databaseSchemaVersion < 42 && isBitcoin === true) { await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0'); } + + if (databaseSchemaVersion < 43 && isBitcoin === true) { + await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records')); + } } /** @@ -791,6 +795,19 @@ class DatabaseMigration { ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } + private getCreateLNNodeRecordsTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS nodes_records ( + public_key varchar(66) NOT NULL, + type int(10) unsigned NOT NULL, + payload blob NOT NULL, + UNIQUE KEY public_key_type (public_key, type), + INDEX (public_key), + FOREIGN KEY (public_key) + REFERENCES nodes (public_key) + ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + public async $truncateIndexedData(tables: string[]) { const allowedTables = ['blocks', 'hashrates', 'prices']; diff --git a/backend/src/api/explorer/nodes.api.ts b/backend/src/api/explorer/nodes.api.ts index d8dceab19..b21544e4b 100644 --- a/backend/src/api/explorer/nodes.api.ts +++ b/backend/src/api/explorer/nodes.api.ts @@ -105,6 +105,18 @@ class NodesApi { node.closed_channel_count = rows[0].closed_channel_count; } + // Custom records + query = ` + SELECT type, payload + FROM nodes_records + WHERE public_key = ? + `; + [rows] = await DB.query(query, [public_key]); + node.custom_records = {}; + for (const record of rows) { + node.custom_records[record.type] = Buffer.from(record.payload, 'binary').toString('hex'); + } + return node; } catch (e) { logger.err(`Cannot get node information for ${public_key}. Reason: ${(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 9b3c62f04..92ae1f0a7 100644 --- a/backend/src/api/lightning/clightning/clightning-convert.ts +++ b/backend/src/api/lightning/clightning/clightning-convert.ts @@ -7,6 +7,15 @@ import { Common } from '../../common'; * Convert a clightning "listnode" entry to a lnd node entry */ export function convertNode(clNode: any): ILightningApi.Node { + let custom_records: { [type: number]: string } | undefined = undefined; + if (clNode.option_will_fund) { + try { + custom_records = { '1': Buffer.from(clNode.option_will_fund.compact_lease || '', 'hex').toString('base64') }; + } catch (e) { + logger.err(`Cannot decode option_will_fund compact_lease for ${clNode.nodeid}). Reason: ` + (e instanceof Error ? e.message : e)); + custom_records = undefined; + } + } return { alias: clNode.alias ?? '', color: `#${clNode.color ?? ''}`, @@ -23,6 +32,7 @@ export function convertNode(clNode: any): ILightningApi.Node { }; }) ?? [], last_update: clNode?.last_timestamp ?? 0, + custom_records }; } diff --git a/backend/src/api/lightning/lightning-api.interface.ts b/backend/src/api/lightning/lightning-api.interface.ts index 1a5e2793f..6e3ea0de3 100644 --- a/backend/src/api/lightning/lightning-api.interface.ts +++ b/backend/src/api/lightning/lightning-api.interface.ts @@ -49,6 +49,7 @@ export namespace ILightningApi { }[]; color: string; features: { [key: number]: Feature }; + custom_records?: { [type: number]: string }; } export interface Info { diff --git a/backend/src/repositories/NodeRecordsRepository.ts b/backend/src/repositories/NodeRecordsRepository.ts new file mode 100644 index 000000000..cf676e35e --- /dev/null +++ b/backend/src/repositories/NodeRecordsRepository.ts @@ -0,0 +1,67 @@ +import { ResultSetHeader, RowDataPacket } from 'mysql2'; +import DB from '../database'; +import logger from '../logger'; + +export interface NodeRecord { + publicKey: string; // node public key + type: number; // TLV extension record type + payload: string; // base64 record payload +} + +class NodesRecordsRepository { + public async $saveRecord(record: NodeRecord): Promise { + try { + const payloadBytes = Buffer.from(record.payload, 'base64'); + await DB.query(` + INSERT INTO nodes_records(public_key, type, payload) + VALUE (?, ?, ?) + ON DUPLICATE KEY UPDATE + payload = ? + `, [record.publicKey, record.type, payloadBytes, payloadBytes]); + } catch (e: any) { + if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this + logger.err(`Cannot save node record (${[record.publicKey, record.type, record.payload]}) into db. Reason: ` + (e instanceof Error ? e.message : e)); + // We don't throw, not a critical issue if we miss some nodes records + } + } + } + + public async $getRecordTypes(publicKey: string): Promise { + try { + const query = ` + SELECT type FROM nodes_records + WHERE public_key = ? + `; + const [rows] = await DB.query(query, [publicKey]); + return rows.map(row => row['type']); + } catch (e) { + logger.err(`Cannot retrieve custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e)); + return []; + } + } + + public async $deleteUnusedRecords(publicKey: string, recordTypes: number[]): Promise { + try { + let query; + if (recordTypes.length) { + query = ` + DELETE FROM nodes_records + WHERE public_key = ? + AND type NOT IN (${recordTypes.map(type => `${type}`).join(',')}) + `; + } else { + query = ` + DELETE FROM nodes_records + WHERE public_key = ? + `; + } + const [result] = await DB.query(query, [publicKey]); + return result.affectedRows; + } catch (e) { + logger.err(`Cannot delete unused custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e)); + return 0; + } + } +} + +export default new NodesRecordsRepository(); diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 70173d6bc..2910f0f9c 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -13,6 +13,7 @@ import fundingTxFetcher from './sync-tasks/funding-tx-fetcher'; import NodesSocketsRepository from '../../repositories/NodesSocketsRepository'; import { Common } from '../../api/common'; import blocks from '../../api/blocks'; +import NodeRecordsRepository from '../../repositories/NodeRecordsRepository'; class NetworkSyncService { loggerTimer = 0; @@ -63,6 +64,7 @@ class NetworkSyncService { let progress = 0; let deletedSockets = 0; + let deletedRecords = 0; const graphNodesPubkeys: string[] = []; for (const node of nodes) { const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key); @@ -84,8 +86,23 @@ class NetworkSyncService { addresses.push(socket.addr); } deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses); + + const oldRecordTypes = await NodeRecordsRepository.$getRecordTypes(node.pub_key); + const customRecordTypes: number[] = []; + for (const [type, payload] of Object.entries(node.custom_records || {})) { + const numericalType = parseInt(type); + await NodeRecordsRepository.$saveRecord({ + publicKey: node.pub_key, + type: numericalType, + payload, + }); + customRecordTypes.push(numericalType); + } + if (oldRecordTypes.reduce((changed, type) => changed || customRecordTypes.indexOf(type) === -1, false)) { + deletedRecords += await NodeRecordsRepository.$deleteUnusedRecords(node.pub_key, customRecordTypes); + } } - logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`); + logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted. ${deletedRecords} custom records deleted.`); // If a channel if not present in the graph, mark it as inactive await nodesApi.$setNodesInactive(graphNodesPubkeys);