balanceofsatoshis
Version:
Lightning balance CLI
607 lines (500 loc) • 17.7 kB
JavaScript
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const {findKey} = require('ln-sync');
const {getNode} = require('ln-service');
const {getNodeAlias} = require('ln-sync');
const {getChannels} = require('ln-service');
const {getPayments} = require('ln-sync');
const {getRebalancePayments} = require('ln-sync');
const moment = require('moment');
const {returnResult} = require('asyncjs-util');
const {chartAliasForPeer} = require('./../display');
const feesForSegment = require('./fees_for_segment');
const {getIcons} = require('./../display');
const {sortBy} = require('./../arrays');
const by = 'confirmed_at';
const daysBetween = (a, b) => moment(a).diff(b, 'days') + 1;
const daysPerWeek = 7;
const defaultDays = 60;
const flatten = arr => [].concat(...arr);
const {floor} = Math;
const heading = [['Node', 'Public Key', 'Fees Paid', 'Forwarded']];
const hoursCount = (a, b) => moment(a).diff(b, 'hours') + 1;
const hoursPerDay = 24;
const isAmbiguous = n => n[1] === 'AmbiguousAliasSpecified';
const {isArray} = Array;
const isDate = n => /^\d{4}(-(0[1-9]|1[0-2]))?(-(0[1-9]|[12][0-9]|3[01]))?$/.test(n);
const {keys} = Object;
const minChartDays = 4;
const maxChartDays = 90;
const mtokensAsBigUnit = n => (Number(n / BigInt(1e3)) / 1e8).toFixed(8);
const mtokensAsTokens = mtokens => Number(mtokens / BigInt(1e3));
const niceAlias = n => `${(n.alias || n.id).trim()} ${n.id.substring(0, 8)}`;
const {now} = Date;
const parseDate = n => Date.parse(n);
const title = 'Routing fees paid';
const tokensAsBigUnit = tokens => (tokens / 1e8).toFixed(8);
const uniq = arr => Array.from(new Set(arr));
/** Get routing fees paid
{
[days]: <Fees Earned Over Days Count Number>
[end_date]: <End Date YYYY-MM-DD String>
fs: {
getFile: <Read File Contents Function> (path, cbk) => {}
}
[in]: <In Node Public Key or Alias String>
[is_most_fees_table]: <Is Most Fees Table Bool>
[is_most_forwarded_table]: <Is Most Forwarded Bool>
[is_network]: <Show Only Non-Peers In Table Bool>
[is_peer]: <Show Only Peers In Table Bool>
lnds: [<Authenticated LND API Object>]
[out]: <Out Node Public Key or Alias String>
[start_date]: <Start Date YYYY-MM-DD String>
}
@returns via cbk or Promise
{
data: [<Earned Fee Tokens Number>]
description: <Chart Description String>
title: <Chart Title String>
}
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!args.fs) {
return cbk([400, 'ExpectedFsMethodsToGetRoutingFeesPaid']);
}
if (!!args.is_network && !!args.is_peer) {
return cbk([400, 'ExpectedEitherNetworkOrPeersNotBoth']);
}
if (!isArray(args.lnds) || !args.lnds.length) {
return cbk([400, 'ExpectedLndToGetRoutingFeesPaid']);
}
// Exit early when there is no end date and no start date
if (!args.end_date && !args.start_date) {
return cbk();
}
if (!!args.days) {
return cbk([400, 'ExpectedEitherDaysOrDatesToGetRoutingFeesPaid']);
}
if (!!args.end_date && !args.start_date) {
return cbk([400, 'ExpectedStartDateToRangeToEndDateForPaidChart']);
}
if (!isDate(args.start_date)) {
return cbk([400, 'ExpectedValidDateTypeForPaidChartStartDate']);
}
if (!moment(args.start_date).isValid()) {
return cbk([400, 'ExpectedValidStartDateForPaidChartEndDate']);
}
if (parseDate(args.start_date) > now()) {
return cbk([400, 'ExpectedPastStartDateToGetRoutingFeesPaid']);
}
// Exit early when there is no end date
if (!args.end_date) {
return cbk();
}
if (args.start_date > args.end_date) {
return cbk([400, 'ExpectedStartDateBeforeEndDateForPaidChart']);
}
if (!isDate(args.end_date)) {
return cbk([400, 'ExpectedValidDateFormatForPaidChartEndDate']);
}
if (!moment(args.end_date).isValid()) {
return cbk([400, 'ExpectedValidEndDateForPaidChartEndDate']);
}
if (parseDate(args.end_date) > now()) {
return cbk([400, 'ExpectedPastEndDateToGetRoutingFeesPaid']);
}
return cbk();
},
// Determine how many days to chart over
days: ['validate', ({}, cbk) => {
// Exit early when not using a date range
if (!args.start_date) {
return cbk(null, args.days || defaultDays);
}
return cbk(null, daysBetween(args.end_date, args.start_date));
}],
// End date for getting fee earnings
end: ['validate', ({}, cbk) => {
if (!args.end_date) {
return cbk();
}
return cbk(null, moment(args.end_date).endOf('day'));
}],
// Get channels
getChannels: ['validate', ({}, cbk) => {
return asyncMap(args.lnds, (lnd, cbk) => {
return getChannels({lnd}, cbk);
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, flatten(res.map(n => n.channels)));
});
}],
// Get node icons
getIcons: ['validate', ({}, cbk) => getIcons({fs: args.fs}, cbk)],
// Determine the in public key to use
getInKey: ['validate', ({}, cbk) => {
// Exit early when no in query is specified
if (!args.in) {
return cbk();
}
return asyncMap(args.lnds, (lnd, cbk) => {
return findKey({lnd, query: args.in}, (err, res) => {
// Exit for ambiguous queries
if (!!err && isAmbiguous(err)) {
return cbk(err);
}
// Ignore all other errors, since a peer may not exist on all nodes
if (!!err) {
return cbk();
}
return cbk(null, res.public_key);
});
},
(err, res) => {
if (!!err) {
return cbk(err);
}
const [key, otherKey] = uniq(res.filter(n => !!n));
if (!key) {
return cbk([400, 'FailedToFindMatchesForInQueryAlias']);
}
if (!!otherKey) {
return cbk([400, 'MultipleMatchesForInQueryAlias']);
}
return cbk(null, key);
});
}],
// Determine the out public key to use
getOutKey: ['validate', ({}, cbk) => {
// Exit early when no out query is specified
if (!args.out) {
return cbk();
}
return asyncMap(args.lnds, (lnd, cbk) => {
return findKey({lnd, query: args.out}, (err, res) => {
// Exit for ambiguous queries
if (!!err && isAmbiguous(err)) {
return cbk(err);
}
// Ignore all other errors, since a peer may not exist on all nodes
if (!!err) {
return cbk();
}
return cbk(null, res.public_key);
});
},
(err, res) => {
if (!!err) {
return cbk(err);
}
const [key, otherKey] = uniq(res.filter(n => !!n));
if (!key) {
return cbk([400, 'FailedToFindMatchesForOutQueryAlias']);
}
if (!!otherKey) {
return cbk([400, 'MultipleMatchesForOutQueryAlias']);
}
return cbk(null, key);
});
}],
// Calculate the start date
start: ['validate', ({}, cbk) => {
if (!!args.start_date) {
return cbk(null, moment(args.start_date));
}
return cbk(null, moment().subtract(args.days || defaultDays, 'days'));
}],
// Segment measure
measure: ['days', ({days}, cbk) => {
if (days > maxChartDays) {
return cbk(null, 'week');
} else if (days < minChartDays) {
return cbk(null, 'hour');
} else {
return cbk(null, 'day');
}
}],
// Get payments
getPayments: ['start', 'validate', ({start}, cbk) => {
// Exit early when only considering rebalance payments
if (!!args.is_rebalances_only) {
return getRebalancePayments({
after: start.toISOString(),
lnds: args.lnds,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, res.payments.map(payment => ({
attempts: payment.paths.map(route => ({route})),
confirmed_at: payment.confirmed_at,
created_at: payment.created_at,
is_confirmed: payment.is_confirmed,
})));
});
}
return asyncMap(args.lnds, (lnd, cbk) => {
return getPayments({after: start.toISOString(), lnd}, cbk);
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, flatten(res.map(n => n.payments)));
});
}],
// Filter the payments
forwards: [
'end',
'getInKey',
'getOutKey',
'getPayments',
'start',
({end, getInKey, getOutKey, getPayments, start}, cbk) =>
{
const payments = getPayments
.filter(payment => payment.is_confirmed !== false)
.filter(payment => payment.confirmed_at > start.toISOString())
.filter(payment => {
// Exit early when there is no end date
if (!args.end_date) {
return true;
}
// The payment should have confirmed within the end range
return payment.confirmed_at <= end.toISOString();
})
.map(payment => {
const attempts = payment.attempts.filter(attempt => {
// Only consider attempts that confirmed
if (attempt.is_confirmed === false) {
return false;
}
const keys = attempt.route.hops.map(n => n.public_key);
const [outHop] = keys;
const [, inHop] = keys.slice().reverse();
if (!outHop) {
return false;
}
// Ignore attempts that do not include the specified out hop
if (!!args.out && outHop !== getOutKey) {
return false;
}
if (!!args.in && !inHop) {
return false;
}
// Ignore attempts that do not include the specified in hop
if (!!args.in && inHop !== getInKey) {
return false;
}
return true;
});
if (!attempts.length) {
return;
}
const totalFees = attempts.reduce((sum, attempt) => {
return sum + BigInt(attempt.route.fee_mtokens);
},
BigInt(Number()));
const totalTokens = attempts.reduce((sum, attempt) => {
return sum + BigInt(attempt.route.mtokens);
},
BigInt(Number()));
return {
attempts,
confirmed_at: payment.confirmed_at,
created_at: payment.created_at,
fee: mtokensAsTokens(totalFees),
fee_mtokens: totalFees.toString(),
mtokens: totalTokens.toString(),
};
})
.filter(n => !!n);
return cbk(null, payments);
}],
// Fees paid to specific forwarding peers
rows: [
'forwards',
'getChannels',
'getIcons',
({forwards, getChannels, getIcons}, cbk) =>
{
if (!args.is_most_forwarded_table && !args.is_most_fees_table) {
return cbk();
}
const fees = forwards.reduce((sum, {attempts}) => {
attempts.forEach(({hops, route}) => {
const usedHops = !!route ? route.hops : hops;
return usedHops.slice().reverse().forEach((hop, i) => {
if (!i) {
return;
}
const key = hop.public_key;
const current = sum[key] || BigInt(Number());
sum[key] = current + BigInt(hop.fee_mtokens);
return;
});
});
return sum;
},
{});
const forwarded = forwards.reduce((sum, {attempts}) => {
attempts.forEach(({hops, route}) => {
const usedHops = !!route ? route.hops : hops;
return usedHops.slice().reverse().forEach((hop, i) => {
if (!i) {
return;
}
const key = hop.public_key;
const current = sum[key] || BigInt(Number());
sum[key] = current + BigInt(hop.forward_mtokens);
return;
});
});
return sum;
},
{});
return asyncMap(keys(fees), (key, cbk) => {
const [lnd] = args.lnds;
return getNode({
lnd,
is_omitting_channels: true,
public_key: key,
},
(err, res) => {
return cbk(null, {
alias: (res || {}).alias,
fees_paid: fees[key],
forwarded: forwarded[key] || BigInt(Number()),
public_key: key,
});
});
},
(err, array) => {
if (!!err) {
return cbk(err);
}
const sort = !!args.is_most_fees_table ? 'fees_paid' : 'forwarded';
const peerKeys = getChannels.map(n => n.partner_public_key);
const rows = sortBy({array, attribute: sort}).sorted
.filter(n => {
// Exit early when there is no peer/network filter
if (!args.is_network && !args.is_peer) {
return true;
}
const isPeer = !!peerKeys.find(key => key === n.public_key);
return !!args.is_peer ? isPeer : !isPeer;
})
.map(node => {
const key = node.public_key;
const nodeIcons = getIcons.nodes.find(n => n.public_key === key);
const {display} = chartAliasForPeer({
alias: node.alias || ' ',
icons: !!nodeIcons ? nodeIcons.icons : undefined,
public_key: key,
});
return [
display,
key,
mtokensAsBigUnit(node.fees_paid),
mtokensAsBigUnit(node.forwarded),
];
});
return cbk(null, [].concat(heading).concat(rows));
});
}],
// Total number of segments
segments: ['days', 'end', 'measure', ({days, end, measure}, cbk) => {
switch (measure) {
case 'hour':
// Exit early when using full days
if (!args.start_date) {
return cbk(null, hoursPerDay * days);
}
return cbk(null, hoursCount(end, args.start_date));
case 'week':
return cbk(null, floor(days / daysPerWeek));
default:
return cbk(null, days);
}
}],
// Total paid
total: ['forwards', ({forwards}, cbk) => {
const paid = forwards.reduce((sum, payment) => {
return sum + BigInt(payment.fee_mtokens);
},
BigInt(Number()));
return cbk(null, mtokensAsTokens(paid));
}],
// Payments activity aggregated
sum: [
'end',
'forwards',
'measure',
'segments',
({end, forwards, measure, segments}, cbk) =>
{
return cbk(null, feesForSegment({
by,
forwards,
measure,
segments,
end: !!end ? end.toISOString() : undefined,
}));
}],
// Summary description of the fees paid
description: [
'end',
'forwards',
'measure',
'start',
'sum',
'total',
({end, forwards, measure, start, sum, total}, cbk) =>
{
const duration = `Fees paid in ${sum.fees.length} ${measure}s`;
const paid = tokensAsBigUnit(total);
const since = `from ${start.calendar().toLowerCase()}`;
const to = !!end ? ` to ${end.calendar().toLowerCase()}` : '';
return cbk(null, `${duration} ${since}${to}. Total: ${paid}`);
}],
// Title for fees paid
title: [
'validate',
'getInKey',
'getOutKey',
async ({getInKey, getOutKey}) =>
{
const [lnd] = args.lnds;
const into = !args.in ? {} : await getNodeAlias({lnd, id: getInKey});
const out = !args.out ? {} : await getNodeAlias({lnd, id: getOutKey});
const inPeer = !!args.in ? `in ${niceAlias(into)}` : '';
const outPeer = !!args.out ? `out ${niceAlias(out)}` : '';
return [title, outPeer, inPeer].filter(n => !!n).join(' ');
}],
// Fees paid
data: [
'description',
'rows',
'sum',
'title',
({description, rows, sum, title}, cbk) =>
{
const isRows = args.is_most_fees_table || args.is_most_forwarded_table;
// Add a title row when there is a restriction involved
if (!!isRows && (!!args.in || !!args.out)) {
rows.unshift([String(), title, String(), String()]);
}
return cbk(null, {description, rows, title, data: sum.fees});
}],
},
returnResult({reject, resolve, of: 'data'}, cbk));
});
};