mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
The 10-minute timeout added for review feedback would have aborted LND's long-poll subscription streams (/v2/invoices/subscribe and /v2/router/track), which legitimately stay open until an invoice settles or a payment resolves - breaking real-time notifications for any invoice paid more than 10 minutes after creation. The wrapper now honors a per-call options.timeout (0 disables the bound, axios semantics; the 10-minute default still applies everywhere else), and both subscription calls pass timeout: 0. They also copy the options object instead of mutating it: addInvoice hands the session-cached options to subscribeToInvoice, so setting the timeout in place would have leaked an unbounded timeout to every subsequent request for that node (getOptions resets form/body/qs but not timeout). Verified on the regtest fixture: with a websocket client connected as alice's frontend, creating an invoice opens the subscription stream, it survives idle, and paying it from the CLN node delivers the SETTLED event over the websocket in real time. The per-call override was also verified directly (timeout: 1000 aborts a slow upstream with ECONNABORTED; timeout: 0 waits it out). Both API suites (31 read + 12 write checks) re-pass.
136 lines
7.6 KiB
TypeScript
136 lines
7.6 KiB
TypeScript
import request from '../../utils/request.js';
|
|
import * as fs from 'fs';
|
|
import { join } from 'path';
|
|
|
|
import { Logger, LoggerService } from '../../utils/logger.js';
|
|
import { Common, CommonService } from '../../utils/common.js';
|
|
import { WSServer } from '../../utils/webSocketServer.js';
|
|
import { SelectedNode } from '../../models/config.model.js';
|
|
|
|
export class LNDWebSocketClient {
|
|
|
|
public logger: LoggerService = Logger;
|
|
public common: CommonService = Common;
|
|
public wsServer = WSServer;
|
|
public webSocketClients: Array<{ selectedNode: SelectedNode }> = [];
|
|
|
|
constructor() {
|
|
this.wsServer.eventEmitterLND.on('CONNECT', (nodeIndex) => {
|
|
this.connect(this.common.findNode(+nodeIndex));
|
|
});
|
|
this.wsServer.eventEmitterLND.on('DISCONNECT', (nodeIndex) => {
|
|
this.disconnect(this.common.findNode(+nodeIndex));
|
|
});
|
|
}
|
|
|
|
public connect = (selectedNode: SelectedNode) => {
|
|
try {
|
|
const clientExists = this.webSocketClients.find((wsc) => wsc.selectedNode.index === selectedNode.index);
|
|
if (!clientExists && selectedNode.settings.lnServerUrl) {
|
|
const newWebSocketClient = { selectedNode: selectedNode };
|
|
this.webSocketClients.push(newWebSocketClient);
|
|
}
|
|
} catch (err: any) {
|
|
throw new Error(err);
|
|
}
|
|
};
|
|
|
|
public fetchUnpaidInvoices = (selectedNode: SelectedNode) => {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Getting Unpaid Invoices..' });
|
|
const options = this.setOptionsForSelNode(selectedNode);
|
|
options.url = selectedNode.settings.lnServerUrl + '/v1/invoices?pending_only=true';
|
|
return request(options).then((body) => {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Unpaid Invoices Received', data: body });
|
|
if (body.invoices && body.invoices.length > 0) {
|
|
body.invoices.forEach((invoice) => {
|
|
if (invoice.state === 'OPEN') {
|
|
this.subscribeToInvoice(options, selectedNode, invoice.r_hash);
|
|
}
|
|
});
|
|
}
|
|
return null;
|
|
}).catch((errRes) => {
|
|
const err = this.common.handleError(errRes, 'WebSocketClient', 'Pending Invoices Error', selectedNode);
|
|
return ({ message: err.message, error: err.error });
|
|
});
|
|
};
|
|
|
|
public subscribeToInvoice = (options: any, selectedNode: SelectedNode, rHash: string) => {
|
|
rHash = rHash?.replace(/\+/g, '-')?.replace(/[/]/g, '_');
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Invoice ' + rHash + ' ..' });
|
|
// Copy the options: the caller may pass the session-cached object, and the
|
|
// long poll needs an unbounded timeout without leaking it to other calls.
|
|
options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash, timeout: 0 };
|
|
request(options).then((msg) => {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Information Received for ' + rHash });
|
|
if (typeof msg === 'string') {
|
|
const results = msg.split('\n');
|
|
msg = (results.length && results.length > 1) ? JSON.parse(results[1]) : JSON.parse(msg);
|
|
msg.result.r_preimage = msg.result.r_preimage ? Buffer.from(msg.result.r_preimage, 'base64').toString('hex') : '';
|
|
msg.result.r_hash = msg.result.r_hash ? Buffer.from(msg.result.r_hash, 'base64').toString('hex') : '';
|
|
msg.result.description_hash = msg.result.description_hash ? Buffer.from(msg.result.description_hash, 'base64').toString('hex') : null;
|
|
}
|
|
msg['type'] = 'invoice';
|
|
msg['source'] = 'LND';
|
|
const msgStr = JSON.stringify(msg);
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Info Received', data: msgStr });
|
|
this.wsServer.sendEventsToAllLNClients(msgStr, selectedNode);
|
|
}).catch((errRes) => {
|
|
const err = this.common.handleError(errRes, 'Invoices', 'Subscribe to Invoice Error for ' + rHash, selectedNode);
|
|
const errStr = ((typeof err === 'object' && err.message) ? JSON.stringify({ error: err.message + ' ' + rHash }) : (typeof err === 'object') ? JSON.stringify({ error: err + ' ' + rHash }) : ('{ "error": ' + err + ' ' + rHash + ' }'));
|
|
this.wsServer.sendErrorToAllLNClients(errStr, selectedNode);
|
|
});
|
|
};
|
|
|
|
public subscribeToPayment = (options: any, selectedNode: SelectedNode, paymentHash: string) => {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Payment ' + paymentHash + ' ..' });
|
|
// Copy the options: the long poll needs an unbounded timeout without
|
|
// leaking it to other calls sharing the object.
|
|
options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash, timeout: 0 };
|
|
request(options).then((msg) => {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Payment Information Received for ' + paymentHash });
|
|
msg['type'] = 'payment';
|
|
msg['source'] = 'LND';
|
|
const msgStr = JSON.stringify(msg);
|
|
this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Payment Info Received', data: msgStr });
|
|
this.wsServer.sendEventsToAllLNClients(msgStr, selectedNode);
|
|
}).catch((errRes) => {
|
|
const err = this.common.handleError(errRes, 'Payment', 'Subscribe to Payment Error for ' + paymentHash, selectedNode);
|
|
const errStr = ((typeof err === 'object' && err.message) ? JSON.stringify({ error: err.message + ' ' + paymentHash }) : (typeof err === 'object') ? JSON.stringify({ error: err + ' ' + paymentHash }) : ('{ "error": ' + err + ' ' + paymentHash + ' }'));
|
|
this.wsServer.sendErrorToAllLNClients(errStr, selectedNode);
|
|
});
|
|
};
|
|
|
|
public setOptionsForSelNode = (selectedNode: SelectedNode) => {
|
|
const options = { url: '', rejectUnauthorized: false, json: true, form: null };
|
|
try {
|
|
options['headers'] = { 'Grpc-Metadata-macaroon': fs.readFileSync(join(selectedNode.authentication.macaroonPath, 'admin.macaroon')).toString('hex') };
|
|
} catch (err) {
|
|
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: 'WebSocketClient', msg: 'Set Options Error', error: JSON.stringify(err) });
|
|
}
|
|
return options;
|
|
};
|
|
|
|
public disconnect = (selectedNode: SelectedNode) => {
|
|
const clientExists = this.webSocketClients.find((wsc) => wsc.selectedNode.index === selectedNode.index);
|
|
if (clientExists) {
|
|
this.logger.log({ selectedNode: clientExists.selectedNode, level: 'INFO', fileName: 'CLWebSocket', msg: 'Disconnecting from the LND\'s Websocket Server..' });
|
|
const clientIdx = this.webSocketClients.findIndex((wsc) => wsc.selectedNode.index === selectedNode.index);
|
|
this.webSocketClients.splice(clientIdx, 1);
|
|
}
|
|
};
|
|
|
|
public updateSelectedNode = (newSelectedNode: SelectedNode) => {
|
|
const clientIdx = this.webSocketClients.findIndex((wsc) => +wsc.selectedNode.index === +newSelectedNode.index);
|
|
let newClient = this.webSocketClients[clientIdx];
|
|
if (!newClient) { newClient = { selectedNode: null }; }
|
|
newClient.selectedNode = JSON.parse(JSON.stringify(newSelectedNode));
|
|
this.webSocketClients[clientIdx] = newClient;
|
|
if (this.webSocketClients[clientIdx].selectedNode.lnVersion === '' || !this.webSocketClients[clientIdx].selectedNode.lnVersion || this.common.isVersionCompatible(this.webSocketClients[clientIdx].selectedNode.lnVersion, '0.11.0')) {
|
|
this.fetchUnpaidInvoices(this.webSocketClients[clientIdx].selectedNode);
|
|
}
|
|
};
|
|
|
|
}
|
|
|
|
export const LNDWSClient = new LNDWebSocketClient();
|