balanceofsatoshis
Version:
Lightning balance CLI
375 lines (306 loc) • 12.5 kB
JavaScript
const {createHash} = require('crypto');
const {randomBytes} = require('crypto');
const asyncAuto = require('async/auto');
const asyncFilterLimit = require('async/filterLimit');
const asyncMapSeries = require('async/mapSeries');
const asyncTimeout = require('async/timeout');
const {createInvoice} = require('ln-service');
const {getChannels} = require('ln-service');
const {getNetworkGraph} = require('ln-service');
const {getRouteToDestination} = require('ln-service');
const {getWalletInfo} = require('ln-service');
const {payViaRoutes} = require('ln-service');
const {returnResult} = require('asyncjs-util');
const {subscribeToProbeForRoute} = require('ln-service');
const getNodesGraph = require('./get_nodes_graph');
const {getTags} = require('./../tags');
const {isMatchingFilters} = require('./../display');
const {shuffle} = require('./../arrays');
const {ceil} = Math;
const cltvDelay = 144;
const createSecret = () => randomBytes(32).toString('hex');
const defaultFilter = ['channels_count > 9'];
const defaultMsg = (alias, key) => `Check out my node! ${alias} ${key}`;
const directPeersDistance = 0;
const featureKeysendBit = 55;
const filterLimit = 10;
const flatten = arr => [].concat(...arr);
const hashOf = n => createHash('sha256').update(n).digest().toString('hex');
const hexAsBuffer = hex => Buffer.from(hex, 'hex');
const invoiceDescription = n => `👀 ${n}`;
const invoiceExpiration = () => new Date(Date.now() + 1000 * 60 * 60 * 24 * 5);
const invoiceTokens = 1;
const {isArray} = Array;
const keySendValueType = '5482373484';
const maxFeeTokens = 10;
const messageWithReply = (msg, req) => `${msg} (Mark seen: ${req})`;
const minChannelCount = 10;
const mtokPerToken = 1e3;
const pathTimeoutMs = 1000 * 45;
const payTimeoutMs = 1000 * 60;
const probeTimeoutMs = 1000 * 60 * 2;
const routeDistance = route => route.hops.length - 1;
const sendTokens = 10;
const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
const textMessageType = '34349334';
const uniq = arr => Array.from(new Set(arr));
const utf8AsHex = utf8 => Buffer.from(utf8, 'utf8').toString('hex');
/** Advertise to nodes that accept KeySend
{
filters: [<Node Condition Filter String>]
fs: {
getFile: <Read File Contents Function> (path, cbk) => {}
}
[is_dry_run]: <Avoid Sending Advertisements Bool>
lnd: <Authenticated LND API Object>
logger: <Winston Logger Object>
[message]: <Message To Send String>
[max_hops]: <Maxmimum Relaying Nodes Number>
[min_hops]: <Minimum Relaying Nodes Number>
tags: [<Tag Name String>]
}
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!isArray(args.filters)) {
return cbk([400, 'ExpectedArrayOfFiltersToAdvertise']);
}
if (!args.fs) {
return cbk([400, 'ExpectedFileSystemMethodsToAdvertise']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToAdvertise']);
}
if (!args.logger) {
return cbk([400, 'ExpectedWinstonLoggerToAdvertise']);
}
if (!isArray(args.tags)) {
return cbk([400, 'ExpectedArrayOfTagsToAdvertiseTo']);
}
return cbk();
},
// Get channels to substitute for graph when ads are for peers only
getChannels: ['validate', ({}, cbk) => {
// Exit early when advertising to more than direct peers
if (args.max_hops !== 0) {
return cbk();
}
return getChannels({lnd: args.lnd}, cbk);
}],
// Get the local identity to compose the ad message
getIdentity: ['validate', ({}, cbk) => {
return getWalletInfo({lnd: args.lnd}, cbk);
}],
// Get the list of tags and filter them.
getTags: ['getIdentity', ({getIdentity}, cbk) => {
// Exit early when there are no tags specified to advertise to
if (!args.tags.length) {
return cbk();
}
return getTags({fs: args.fs}, (err, res) => {
if (!!err) {
return cbk(err);
}
// Look for a referenced tag that doesn't match a present tag
const unknown = args.tags.find(tagName => {
return !res.tags.find(t => t.alias === tagName);
});
// Make sure the tag is present
if (!!unknown) {
return cbk([404, 'FailedToFindTagWithSpecifiedName', {unknown}]);
}
// Collect all the node identity keys
const matches = args.tags.map(tagName => {
const tag = res.tags.find(t => t.alias === tagName);
return tag.nodes.filter(n => n !== getIdentity.public_key);
});
// Merge all tagged nodes and remove duplicates
return cbk(null, uniq(flatten(matches)));
});
}],
// Get the graph to use to find candidates to send ads to
getGraph: ['getChannels', 'getTags', ({getChannels, getTags}, cbk) => {
// Exit early when when advertising to tags.
if (!!args.tags.length) {
return getNodesGraph({lnd: args.lnd, nodes: getTags}, cbk);
}
// Exit early when using the entire graph
if (args.max_hops !== directPeersDistance) {
args.logger.info({sending_to_all_graph_nodes: true});
return getNetworkGraph({lnd: args.lnd}, cbk);
}
// Collect all ids of channel peers
const nodes = getChannels.channels.map(n => n.partner_public_key);
return getNodesGraph({lnd: args.lnd, nodes: uniq(nodes)}, cbk);
}],
// Derive the message to send
message: ['getIdentity', ({getIdentity}, cbk) => {
const alias = getIdentity.alias || String();
const identity = getIdentity.public_key;
const message = args.message || defaultMsg(alias, identity);
args.logger.info({sending_message: message});
return cbk(null, message);
}],
// Filter out nodes where there is no path
nodes: [
'getGraph',
'getIdentity',
'message',
({getGraph, getIdentity, message}, cbk) =>
{
const {shuffled} = shuffle({array: getGraph.nodes});
// Only consider 3rd party nodes that have known features
const filteredNodes = shuffled
.filter(n => n.public_key !== getIdentity.public_key)
.filter(n => !!n.features.length)
.filter(n => n.features.map(n => n.bit).includes(featureKeysendBit))
.map(node => {
const channels = getGraph.channels.filter(channel => {
const {policies} = channel;
return policies.find(n => n.public_key === node.public_key);
});
const filters = args.filters.length ? args.filters : defaultFilter;
const variables = {
capacity: sumOf(channels.map(n => n.capacity)),
channels_count: channels.length,
};
const isMatching = isMatchingFilters({filters, variables});
// Exit early when there is a failure with a filter
if (isMatching.failure) {
return {failure: isMatching.failure};
}
return !!isMatching.is_matching ? {node} : {};
});
const failure = filteredNodes.find(n => !!n.failure);
if (!!filteredNodes.find(n => !!n.failure)) {
return cbk([400, 'ExpectedValidFiltersForNodes', {failure}]);
}
const nodes = filteredNodes.map(n => n.node).filter(n => !!n);
args.logger.info({potential_nodes: nodes.length});
return asyncFilterLimit(nodes, filterLimit, (node, cbk) => {
return getRouteToDestination({
destination: node.public_key,
lnd: args.lnd,
max_fee: maxFeeTokens,
messages: [
{type: keySendValueType, value: createSecret()},
{type: textMessageType, value: utf8AsHex(message)},
],
tokens: sendTokens,
},
(err, res) => {
// Exit early when there is a problem getting a route
if (!!err || !res.route) {
return cbk(null, false);
}
const relaysCount = routeDistance(res.route);
// Exit early when there are too many relaying nodes
if (args.max_hops !== undefined && relaysCount > args.max_hops) {
return cbk(null, false);
}
// Exit early when there are too few relaying nodes
if (args.min_hops !== undefined && relaysCount < args.min_hops) {
return cbk(null, false);
}
// There exists a route to the destination within range constraints
return cbk(null, true);
});
},
cbk);
}],
// Send message to nodes
send: ['message', 'nodes', ({message, nodes}, cbk) => {
const maxSpendPerNode = sendTokens + maxFeeTokens;
const sent = [];
args.logger.info({routable_nodes: nodes.length});
return asyncMapSeries(nodes, (node, cbk) => {
const paidTokens = ceil(Number(sent.reduce((sum, n) => {
return sum + BigInt(n.mtokens) + BigInt(n.fee_mtokens);
},
BigInt(Number()))) / mtokPerToken);
// Check that the potential next ad would not go over budget
if (!!args.budget && paidTokens + maxSpendPerNode > args.budget) {
return cbk([400, 'AdvertisingBudgetExhausted']);
}
const probe = subscribeToProbeForRoute({
cltv_delay: cltvDelay,
destination: node.public_key,
lnd: args.lnd,
max_fee: maxFeeTokens,
messages: [
{type: keySendValueType, value: createSecret()},
{type: textMessageType, value: utf8AsHex(message)},
],
path_timeout_ms: pathTimeoutMs,
probe_timeout_ms: probeTimeoutMs,
tokens: sendTokens,
});
let isFinished = false;
const timeout = setTimeout(() => {
isFinished = true;
probe.removeAllListeners();
return cbk();
},
payTimeoutMs);
probe.on('end', () => {
if (isFinished) {
return;
}
clearTimeout(timeout);
return cbk();
});
probe.on('error', () => {});
probe.on('probe_success', async ({route}) => {
try {
// Create a "mark seen" invoice
const invoice = await createInvoice({
description: invoiceDescription(node.public_key),
expires_at: invoiceExpiration(),
lnd: args.lnd,
tokens: invoiceTokens,
});
// Add a reply payment request to the message
const finalMessage = messageWithReply(message, invoice.request);
// Generate the preimage
const secret = createSecret();
// Encode the preimage and the advertising message in messages
route.messages = [
{type: keySendValueType, value: secret},
{type: textMessageType, value: utf8AsHex(finalMessage)},
];
// Exit early when this is a dry run
if (!!args.is_dry_run) {
return args.logger.info({skipping_due_to_dry_run: node.alias});
}
// Send the payment
const paid = await payViaRoutes({
id: hashOf(hexAsBuffer(secret)),
lnd: args.lnd,
routes: [route],
});
sent.push(paid);
args.logger.info({
sent_to: `${node.alias || String()} ${node.public_key}`,
paid: paid.fee + paid.tokens,
total: {
ads: sent.length,
paid: ceil(Number(sent.reduce((sum, n) => {
return sum + BigInt(n.mtokens) + BigInt(n.fee_mtokens);
},
BigInt(Number()))) / mtokPerToken),
},
});
} catch (err) {
}
});
return;
},
cbk);
}],
},
returnResult({reject, resolve}, cbk));
});
};