UNPKG

balanceofsatoshis

Version:
1,048 lines (906 loc) 34.4 kB
const {actOnMessageReply} = require('ln-telegram'); const asyncAuto = require('async/auto'); const asyncEach = require('async/each'); const asyncForever = require('async/forever'); const asyncMap = require('async/map'); const asyncRetry = require('async/retry'); const {getForwards} = require('ln-service'); const {getNetwork} = require('ln-service'); const {getTransactionRecord} = require('ln-sync'); const {getWalletInfo} = require('ln-service'); const {handleBackupCommand} = require('ln-telegram'); const {handleBalanceCommand} = require('ln-telegram'); const {handleBlocknotifyCommand} = require('ln-telegram'); const {handleButtonPush} = require('ln-telegram'); const {handleConnectCommand} = require('ln-telegram'); const {handleCostsCommand} = require('ln-telegram'); const {handleEarningsCommand} = require('ln-telegram'); const {handleEditedMessage} = require('ln-telegram'); const {handleGraphCommand} = require('ln-telegram'); const {handleInfoCommand} = require('ln-telegram'); const {handleInvoiceCommand} = require('ln-telegram'); const {handleLiquidityCommand} = require('ln-telegram'); const {handleMempoolCommand} = require('ln-telegram'); const {handlePayCommand} = require('ln-telegram'); const {handlePendingCommand} = require('ln-telegram'); const {handleStartCommand} = require('ln-telegram'); const {handleStopCommand} = require('ln-telegram'); const {handleVersionCommand} = require('ln-telegram'); const {InputFile} = require('grammy'); const {isMessageReplyAction} = require('ln-telegram'); const {noLocktimeIdForTransaction} = require('@alexbosworth/blockchain'); const {notifyOfForwards} = require('ln-telegram'); const {postChainTransaction} = require('ln-telegram'); const {postClosedMessage} = require('ln-telegram'); const {postClosingMessage} = require('ln-telegram'); const {postCreatedTrade} = require('ln-telegram'); const {postOpenMessage} = require('ln-telegram'); const {postOpeningMessage} = require('ln-telegram'); const {postNodesOnline} = require('ln-telegram'); const {postSettledInvoice} = require('ln-telegram'); const {postSettledPayment} = require('ln-telegram'); const {postSettledTrade} = require('ln-telegram'); const {postUpdatedBackup} = require('ln-telegram'); const {returnResult} = require('asyncjs-util'); const {serviceAnchoredTrades} = require('paid-services'); const {subscribeToBackups} = require('ln-service'); const {subscribeToBlocks} = require('goldengate'); const {subscribeToChannels} = require('ln-service'); const {subscribeToInvoices} = require('ln-service'); const {subscribeToPastPayments} = require('ln-service'); const {subscribeToPendingChannels} = require('ln-sync'); const {subscribeToTransactions} = require('ln-service'); const getNodeDetails = require('./get_node_details'); const interaction = require('./interaction'); const named = require('./../package').name; const {version} = require('./../package'); const fileAsDoc = file => new InputFile(file.source, file.filename); const fromName = node => `${node.alias} ${node.public_key.substring(0, 8)}`; const getLnds = (x, y, z) => getNodeDetails({logger: x, names: y, nodes: z}); const hexAsBuffer = hex => Buffer.from(hex, 'hex'); const {isArray} = Array; const isHash = n => /^[0-9A-F]{64}$/i.test(n); let isBotInit = false; const isNumber = n => !isNaN(n); const limit = 99999; const markdown = {parse_mode: 'Markdown'}; const maxCommandDelayMs = 1000 * 10; const restartSubscriptionTimeMs = 1000 * 30; const sanitize = n => (n || '').replace(/_/g, '\\_').replace(/[*~`]/g, ''); /** Start a Telegram bot { ask: <Ask Function> bot: <Telegram Bot Object> [id]: <Authorized User Id Number> key: <Telegram API Key String> [min_forward_tokens]: <Minimum Forward Tokens To Notify Number> [min_rebalance_tokens]: <Minimum Rebalance Tokens To Notify Number> lnds: [<Authenticated LND API Object>] nodes: [<Saved Nodes String>] logger: <Winston Logger Object> payments_limit: <Total Spendable Budget Tokens Limit Number> request: <Request Function> } @returns via cbk or Promise { [connected]: <Connected User Id Number> failure: <Termination Error Object> } */ module.exports = (args, cbk) => { let connectedId = args.id; let isStopped = false; let paymentsLimit = args.payments_limit; const subscriptions = []; return new Promise((resolve, reject) => { return asyncAuto({ // Check arguments validate: cbk => { if (!args.ask) { return cbk([400, 'ExpectedAskFunctionToStartTelegramBot']); } if (!isArray(args.lnds) || !args.lnds.length) { return cbk([400, 'ExpectedLndsToStartTelegramBot']); } if (!args.key) { return cbk([400, 'ExpectedApiKeyToStartTelegramBot']); } if (!!args.id && args.key.startsWith(`${args.id}:`)){ return cbk([400, 'ExpectedConnectCodeFromConnectCommandNotBotId']); } if (!args.logger) { return cbk([400, 'ExpectedLoggerToStartTelegramBot']); } if (!isArray(args.nodes)) { return cbk([400, 'ExpectedArrayOfSavedNodesToStartTelegramBot']); } if (!isNumber(args.payments_limit)) { return cbk([400, 'ExpectedPaymentsLimitTokensNumberToStartBot']); } if (!args.request) { return cbk([400, 'ExpectedRequestMethodToStartTelegramBot']); } return cbk(); }, // Get node info getNodes: ['validate', ({}, cbk) => { return asyncMap(args.lnds, (lnd, cbk) => { return getWalletInfo({lnd}, (err, res) => { if (!!err) { return cbk([503, 'FailedToGetNodeInfo', {err}]); } const named = fromName({ alias: res.alias, public_key: res.public_key, }); return cbk(null, { lnd, alias: res.alias, from: sanitize(named), public_key: res.public_key, }); }); }, cbk); }], // Setup the bot start action initBot: ['getNodes', ({getNodes}, cbk) => { // Exit early when the bot was already setup if (!!isBotInit) { return cbk(); } const names = getNodes.map(node => ({ alias: node.alias, from: node.from, public_key: node.public_key, })); args.bot.catch(err => args.logger.error({telegram_error: err})); // Catch message editing args.bot.use(async (ctx, next) => { try { await handleEditedMessage({ctx}); } catch (err) { args.logger.error({err}); } return next(); }); // Handle command to get backups args.bot.command('backup', async ctx => { try { await handleBackupCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: ctx.reply, send: (n, opts) => ctx.replyWithDocument(fileAsDoc(n), opts), }); } catch (err) { args.logger.error({err}); } }); // Handle lookup of total funds args.bot.command('balance', async ctx => { try { await handleBalanceCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: (n, opt) => ctx.reply(n, opt), working: () => ctx.replyWithChatAction('typing'), }); } catch (err) { args.logger.error({err}); } }); // Handle command to get notified on the next block args.bot.command('blocknotify', ctx => { handleBlocknotifyCommand({ from: ctx.message.from.id, id: connectedId, reply: n => ctx.reply(n, markdown), request: args.request, }, err => !!err ? args.logger.error({err}) : null); }); // Handle command to get the connect id args.bot.command('connect', ctx => { handleConnectCommand({ from: ctx.from.id, id: connectedId, reply: n => ctx.reply(n, markdown), }); }); // Handle command to view costs over the past week args.bot.command('costs', async ctx => { try { await handleCostsCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: n => ctx.reply(n, markdown), request: args.request, working: () => ctx.replyWithChatAction('typing'), }); } catch (err) { args.logger.error({err}); } }); // Handle command to view earnings over the past week args.bot.command('earnings', async ctx => { try { await handleEarningsCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: n => ctx.reply(n, markdown), working: () => ctx.replyWithChatAction('typing'), }); } catch (err) { args.logger.error({err}); } }); // Handle command to look up nodes in the graph args.bot.command('graph', async ctx => { try { await handleGraphCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, remove: () => ctx.deleteMessage(), reply: (message, options) => ctx.reply(message, options), text: ctx.message.text, working: () => ctx.replyWithChatAction('typing'), }); } catch (err) { args.logger.error({err}); } }); // Handle command to look up wallet info args.bot.command('info', async ctx => { try { await handleInfoCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, remove: () => ctx.deleteMessage(), reply: (message, options) => ctx.reply(message, options), }); } catch (err) { args.logger.error({err}); } }); // Handle creation of an invoice args.bot.command('invoice', async ctx => { try { await handleInvoiceCommand({ ctx, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, request: args.request, }); } catch (err) { args.logger.error({err}); } }); // Handle lookup of the mempool args.bot.command('mempool', async ctx => { try { return await handleMempoolCommand({ from: ctx.message.from.id, id: connectedId, reply: n => ctx.reply(n, markdown), request: args.request, }); } catch (err) { args.logger.error({err}); } }); // Handle lookup of channel liquidity args.bot.command('liquidity', async ctx => { try { await asyncRetry({ errorFilter: err => { if (err && /^404/.test(err.message)) { return false; } return true; }, }, async () => { await handleLiquidityCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: (n, opt) => ctx.reply(n, opt), text: ctx.message.text, working: () => ctx.replyWithChatAction('typing'), }); }); } catch (err) { args.logger.error({err}); } }); // Handle command to pay a payment request args.bot.command('pay', async ctx => { const budget = paymentsLimit; if (!budget) { ctx.reply(interaction.pay_budget_depleted); return; } // Stop budget while payment is in flight paymentsLimit = 0; try { const {tokens} = await handlePayCommand({ budget, from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: message => ctx.reply(message, markdown), request: args.request, text: ctx.message.text, }); // Set the payments limit to the amount unspent by the pay command paymentsLimit = budget - tokens; } catch (err) { args.logger.error({payment_error: err}); } }); // Handle command to view pending transactions args.bot.command('pending', async ctx => { try { await handlePendingCommand({ from: ctx.message.from.id, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, reply: (message, options) => ctx.reply(message, options), working: () => ctx.replyWithChatAction('typing'), }); } catch (err) { args.logger.error({err}); } }); // Handle command to start the bot args.bot.command('start', ctx => { handleStartCommand({ id: connectedId, reply: n => ctx.reply(n, markdown), }); }); // Terminate the running bot args.bot.command('stop', async ctx => { try { await handleStopCommand({ from: ctx.message.from.id, id: connectedId, reply: (msg, mode) => ctx.reply(msg, mode), }); } catch (err) { args.logger.error({err}); } }); // Handle command to view the current version args.bot.command('version', async ctx => { try { await handleVersionCommand({ named, version, from: ctx.message.from.id, id: connectedId, request: args.request, reply: n => ctx.reply(n, markdown), }); } catch (err) { args.logger.error({err}); } }); // Handle command to get help with the bot args.bot.command('help', async ctx => { const commands = [ '/backup - Get node backup file', '/blocknotify - Notification on next block', '/connect - Connect bot', '/costs - View costs over the past week', '/earnings - View earnings over the past week', '/graph <pubkey or peer alias> - Show info about a node', '/info - Show wallet info', '/invoice [amount] [memo] - Make an invoice', '/liquidity [with] - View node liquidity', '/mempool - BTC mempool report', '/pay - Pay an invoice', '/pending - View pending channels, probes, and forwards', '/stop - Stop bot', '/version - View the current bot version', ]; try { await ctx.reply(`🤖\n${commands.join('\n')}`); } catch (err) { args.logger.error({err}); } }); // Handle button push type commands args.bot.on('callback_query:data', async ctx => { try { await handleButtonPush({ ctx, bot: args.bot, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, }); } catch (err) { args.logger.error({err}); } }); // Listen for replies to created invoice messages args.bot.on('message').filter( ctx => isMessageReplyAction({ctx, nodes: getNodes}), async ctx => { try { return await actOnMessageReply({ ctx, api: args.bot.api, id: connectedId, nodes: (await getLnds(args.logger, names, args.nodes)).nodes, request: args.request, }); } catch (err) { args.logger.error({err}); } }, ); args.bot.start(); // Avoid re-registering bot actions isBotInit = true; return cbk(); }], // Ask the user to confirm their user id userId: ['initBot', ({}, cbk) => { // Exit early when the id is specified if (!!connectedId) { return cbk(); } return args.ask({ message: interaction.user_id_prompt.message, name: 'code', type: 'input', validate: input => { if (!input) { return false; } // The connect code should be entirely numeric, not an API key if (!isNumber(input)) { return `Expected numeric connect code from /connect command`; } // the connect code number should not match bot id from the API key if (args.key.startsWith(`${input}:`)) { return `Expected /connect code, not bot id from API key`; } return true; }, }, ({code}) => { if (!code) { return cbk([400, 'ExpectedConnectCodeToStartTelegramBot']); } connectedId = Number(code); return cbk(); }); }], // Setup the bot commands setCommands: ['validate', async ({}) => { return await args.bot.api.setMyCommands([ {command: 'backup', description: 'Get node backup file'}, {command: 'balance', description: 'Show funds on the node'}, {command: 'blocknotify', description: 'Get notified on next block'}, {command: 'connect', description: 'Get connect code for the bot'}, {command: 'costs', description: 'Show costs over the week'}, {command: 'earnings', description: 'Show earnings over the week'}, {command: 'graph', description: 'Show info about a node'}, {command: 'help', description: 'Show the list of commands'}, {command: 'info', description: 'Show wallet info'}, {command: 'invoice', description: 'Create an invoice [amt] [memo]'}, {command: 'liquidity', description: 'Get liquidity [with-peer]'}, {command: 'mempool', description: 'Get info about the mempool'}, {command: 'pay', description: 'Pay a payment request'}, {command: 'pending', description: 'Get pending forwards, channels'}, {command: 'stop', description: 'Stop the bot'}, {command: 'version', description: 'View current bot version'}, ]); }], // Subscribe to backups backups: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, (node, cbk) => { let postBackupTimeoutHandle; const sub = subscribeToBackups({lnd: node.lnd}); subscriptions.push(sub); sub.on('backup', ({backup}) => { // Cancel pending backup notification when there is a new backup if (!!postBackupTimeoutHandle) { clearTimeout(postBackupTimeoutHandle); } // Time delay backup posting to avoid posting duplicate messages postBackupTimeoutHandle = setTimeout(() => { return postUpdatedBackup({ backup, id: connectedId, key: args.key, node: {alias: node.alias, public_key: node.public_key}, send: (id, n) => args.bot.api.sendDocument(id, fileAsDoc(n)), }, err => !!err ? args.logger.error({post_backup_err: err}) : null); }, restartSubscriptionTimeMs); return; }); sub.once('error', err => { // Terminate subscription and restart after a delay sub.removeAllListeners(); return cbk([503, 'ErrorInBackupsSub', {err}]); }); }, cbk); }], // Channel status changes channels: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, ({from, lnd}, cbk) => { const sub = subscribeToChannels({lnd}); subscriptions.push(sub); sub.on('channel_closed', async update => { try { await postClosedMessage({ from, lnd, capacity: update.capacity, id: connectedId, is_breach_close: update.is_breach_close, is_cooperative_close: update.is_cooperative_close, is_local_force_close: update.is_local_force_close, is_remote_force_close: update.is_remote_force_close, partner_public_key: update.partner_public_key, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }); } catch (err) { args.logger.error({from, post_closed_message_error: err}); } }); sub.on('channel_opened', async update => { try { await postOpenMessage({ from, lnd, capacity: update.capacity, id: connectedId, is_partner_initiated: update.is_partner_initiated, is_private: update.is_private, partner_public_key: update.partner_public_key, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }); } catch (err) { args.logger.error({from, post_open_message_error: err}); } }); sub.once('error', err => { // Terminate subscription and restart after a delay sub.removeAllListeners(); return cbk([503, 'UnexpectedErrorInChannelsSubscription', {err}]); }); return; }, cbk); }], // Send connected message connected: ['getNodes', 'userId', ({getNodes}, cbk) => { args.logger.info({is_connected: true}); return postNodesOnline({ id: connectedId, nodes: getNodes.map(n => ({alias: n.alias, id: n.public_key})), send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }, cbk); }], // Poll for forwards forwards: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, (node, cbk) => { let after = new Date().toISOString(); const {from} = node; const {lnd} = node; return asyncForever(cbk => { if (isStopped) { return cbk([503, 'ExpectedNonStoppedBotToReportForwards']); } const before = new Date().toISOString(); return getForwards({after, before, limit, lnd}, (err, res) => { // Exit early and ignore errors if (!!err) { return setTimeout(cbk, restartSubscriptionTimeMs); } // Push cursor forward after = before; // Notify Telegram bot that forwards happened return notifyOfForwards({ from, lnd, forwards: res.forwards.filter(forward => { if (!args.min_forward_tokens) { return true; } return forward.tokens >= args.min_forward_tokens; }), id: connectedId, node: node.public_key, nodes: getNodes, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }, err => { if (!!err) { args.logger.error({forwards_notify_err: err}); } return setTimeout(cbk, restartSubscriptionTimeMs); }); }); }, cbk); }, cbk); }], // Subscribe to invoices invoices: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, (node, cbk) => { const sub = subscribeToInvoices({lnd: node.lnd}); subscriptions.push(sub); sub.on('invoice_updated', invoice => { // Exit early when an invoice has no associated hash if (!isHash(invoice.id)) { return; } return postSettledInvoice({ from: node.from, id: connectedId, invoice: { confirmed_at: invoice.confirmed_at, description: invoice.description, id: invoice.id, is_confirmed: invoice.is_confirmed, is_push: invoice.is_push, payments: invoice.payments, received: invoice.received, received_mtokens: invoice.received_mtokens, }, key: node.public_key, lnd: node.lnd, min_rebalance_tokens: args.min_rebalance_tokens, nodes: getNodes, quiz: ({answers, correct, question}) => { return args.bot.api.sendQuiz( connectedId, question, answers, {correct_option_id: correct}, ); }, send: (id, msg, opts) => args.bot.api.sendMessage(id, msg, opts), }, err => !!err ? args.logger.error({settled_err: err}) : null); }); sub.on('error', err => { const from = node.from; sub.removeAllListeners(); args.logger.error({invoices_err: err}); return cbk([503, 'InvoicesSubscriptionFailed', {err, from}]); }); }, cbk); }], // Subscribe to past payments payments: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, (node, cbk) => { const sub = subscribeToPastPayments({lnd: node.lnd}); subscriptions.push(sub); sub.on('payment', async payment => { // Ignore rebalances if (payment.destination === node.public_key) { return; } try { await postSettledPayment({ from: node.from, id: connectedId, lnd: node.lnd, nodes: getNodes.map(n => n.public_key), payment: { destination: payment.destination, id: payment.id, paths: payment.paths, safe_fee: payment.safe_fee, safe_tokens: payment.safe_tokens, }, send: (id, m, opts) => args.bot.api.sendMessage(id, m, opts), }); } catch (err) { args.logger.error({post_payment_error: err}); } }); sub.once('error', err => { // Terminate subscription and restart after a delay sub.removeAllListeners(); return cbk([503, 'ErrorInPaymentsSub', {err}]) }); }, cbk); }], // Pending channels changes pending: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, ({from, lnd}, cbk) => { const sub = subscribeToPendingChannels({lnd}); subscriptions.push(sub); // Listen for pending closing channel events sub.on('closing', async update => { try { await postClosingMessage({ from, lnd, closing: update.channels, id: connectedId, nodes: getNodes, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }); } catch (err) { args.logger.error({from, post_closing_message_error: err}); } }); // Listen for pending opening events sub.on('opening', async update => { try { await postOpeningMessage({ from, lnd, id: connectedId, opening: update.channels, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), }); } catch (err) { args.logger.error({from, post_opening_message_error: err}); } }); sub.once('error', err => { // Terminate subscription and restart after a delay sub.removeAllListeners(); return cbk([503, 'UnexpectedErrorInPendingSubscription', {err}]); }); return; }, cbk); }], // Service trade secrets secrets: ['getNodes', 'userId', ({getNodes}, cbk) => { return asyncEach(getNodes, (node, cbk) => { const start = new Date().toISOString(); const sub = serviceAnchoredTrades({ lnd: node.lnd, request: args.request, }); subscriptions.push(sub); sub.on('settled', async trade => { // Ignore trades without tokens if (!trade.tokens) { return; } try { await postSettledTrade({ api: args.bot.api, description: trade.description, destination: node.public_key, lnd: node.lnd, nodes: getNodes, to: trade.to, tokens: trade.tokens, user: connectedId, }); } catch (err) { args.logger.error({err}); } }); sub.on('start', async trade => { // Exit early when this is an older trade or has no tokens if (!trade.tokens || trade.created_at < start) { return; } try { await postCreatedTrade({ api: args.bot.api, description: trade.description, destination: node.public_key, expires_at: trade.expires_at, id: trade.id, lnd: node.lnd, nodes: getNodes, tokens: trade.tokens, user: connectedId, }); } catch (err) { args.logger.error({err}); } }); sub.once('error', err => { sub.removeAllListeners(); args.logger.error({err}); return cbk(err); }); return; }, cbk); }], // Subscribe to chain transactions transactions: ['getNodes', 'userId', ({getNodes}, cbk) => { let isFinished = false; return asyncEach(getNodes, ({from, lnd}, cbk) => { const noLocktimeIds = []; const sub = subscribeToTransactions({lnd}); const transactions = []; subscriptions.push(sub); sub.on('chain_transaction', async transaction => { const {id} = transaction; // Exit early when this pending transaction has already been seen if (!transaction.is_confirmed && transactions.includes(id)) { return; } transactions.push(id); // Check the transaction uniqueness against a locktime-absent hash if (!transaction.is_confirmed && !!transaction.transaction) { try { const buffer = hexAsBuffer(transaction.transaction); const noLocktimeId = noLocktimeIdForTransaction({buffer}).id; // Exit early when a similar transaction has already been seen if (noLocktimeIds.includes(noLocktimeId)) { return; } noLocktimeIds.push(noLocktimeId); } catch (err) { args.logger.error({err}); } } try { const record = await getTransactionRecord({lnd, id}); if (!record || !record.tx) { return; } return await postChainTransaction({ from, confirmed: transaction.is_confirmed, id: connectedId, nodes: getNodes, send: (id, msg, opt) => args.bot.api.sendMessage(id, msg, opt), transaction: record, }); } catch (err) { args.logger.error({chain_tx_err: err, node: from}); if (!!isFinished) { return; } isFinished = true; sub.removeAllListeners({}); return cbk(err); } }); sub.once('error', err => { sub.removeAllListeners(); if (!!isFinished) { return; } isFinished = true; args.logger.error({from, chain_subscription_error: err}); return cbk(err); }); return; }, cbk); }], }, (err, res) => { // Signal to fetch based polling that it should stop isStopped = true; // Cancel all open subscriptions subscriptions.forEach(n => n.removeAllListeners()); const result = {result: {connected: connectedId, failure: err}}; return returnResult({reject, resolve, of: 'result'}, cbk)(null, result); }); }); };