mirror of
https://github.com/alexbosworth/balanceofsatoshis.git
synced 2026-08-13 12:33:37 +02:00
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
const {isIP} = require('net');
|
|
|
|
const asyncAuto = require('async/auto');
|
|
const asyncMap = require('async/map');
|
|
const {decodeChanId} = require('bolt07');
|
|
const {findKey} = require('ln-sync');
|
|
const {formatTokens} = require('ln-sync');
|
|
const {getHeight} = require('ln-service');
|
|
const {getNetworkGraph} = require('ln-service');
|
|
const {getNode} = require('ln-service');
|
|
const {getNodeAlias} = require('ln-sync');
|
|
const {getRouteToDestination} = require('ln-service');
|
|
const moment = require('moment');
|
|
const {returnResult} = require('asyncjs-util');
|
|
|
|
const {chartAliasForPeer} = require('./../display');
|
|
const {emojiIcons} = require('./constants');
|
|
const {formatFeeRate} = require('./../display');
|
|
const {getIcons} = require('./../display');
|
|
const {isMatchingFilters} = require('./../display');
|
|
|
|
const ageMs = time => moment(new Date(Date.now() - time)).fromNow(true);
|
|
const blockTime = (current, start) => 1000 * 60 * 10 * (current - start);
|
|
const defaultSort = 'age';
|
|
const disableTag = isDisabled => isDisabled ? `${emojiIcons.disabled} ` : '';
|
|
const displayDiscount = fee => !!fee && isFinite(fee) ? ` ←${-fee}` : '';
|
|
const displayFee = (n, rate) => n.length ? formatFeeRate({rate}).display : ' ';
|
|
const displayTokens = tokens => formatTokens({tokens}).display;
|
|
const distanceTokens = 100;
|
|
const hasDistanceFilter = filters => /hops/gim.test(filters.join(' '));
|
|
const header = [['Alias','Age','In Fee','Capacity','Out Fee','Public Key']];
|
|
const hopsTitle = 'Hops';
|
|
const {isArray} = Array;
|
|
const isClear = sockets => !!sockets.find(n => !!isIP(n.socket.split(':')[0]));
|
|
const isLarge = features => !!features.find(n => n.type === 'large_channels');
|
|
const isOnion = sockets => !!sockets.find(n => /onion/.test(n.socket));
|
|
const isPublicKey = n => !!n && /^0[2-3][0-9A-F]{64}$/i.test(n);
|
|
const join = arr => arr.join('');
|
|
const {max} = Math;
|
|
const {min} = Math;
|
|
const sortBy = (a, b) => a > b ? 1 : (a !== b ? -1 : 0);
|
|
const sorts = ['capacity', 'age', 'in_fee', 'out_fee'];
|
|
const uniq = arr => Array.from(new Set(arr));
|
|
|
|
/** Get a graph entry
|
|
|
|
{
|
|
filters: [<Filter Expression String>]
|
|
fs: {
|
|
getFile: <Read File Contents Function> (path, cbk) => {}
|
|
}
|
|
lnd: <Authenticated LND API Object>
|
|
logger: <Winston Logger Object>
|
|
query: <Graph Query String>
|
|
sort: <Sort By Field String>
|
|
}
|
|
|
|
@returns via cbk or Promise
|
|
{
|
|
rows: [[<Table Cell String>]]
|
|
}
|
|
*/
|
|
module.exports = ({filters, fs, lnd, logger, query, sort}, cbk) => {
|
|
return new Promise((resolve, reject) => {
|
|
return asyncAuto({
|
|
// Check arguments
|
|
validate: cbk => {
|
|
if (!isArray(filters)) {
|
|
return cbk([400, 'ExpectedArrayOfFiltersToGetGraphEntry']);
|
|
}
|
|
|
|
if (!fs) {
|
|
return cbk([400, 'ExpectedFsMethodsToGetGraphEntry']);
|
|
}
|
|
|
|
if (!lnd) {
|
|
return cbk([400, 'ExpectedAuthenticatedLndToGetGraphEntry']);
|
|
}
|
|
|
|
if (!logger) {
|
|
return cbk([400, 'ExpectedWinstonLoggerToGetGraphEntry']);
|
|
}
|
|
|
|
if (!query) {
|
|
return cbk([400, 'ExpectedQueryToGetGraphEntry']);
|
|
}
|
|
|
|
if (!!sort && !sorts.includes(sort)) {
|
|
return cbk([400, 'ExpectedKnownSortType', {sorts}]);
|
|
}
|
|
|
|
return cbk();
|
|
},
|
|
|
|
// Get the current block height to use for age calculations
|
|
getHeight: ['validate', ({}, cbk) => getHeight({lnd}, cbk)],
|
|
|
|
// Get the tagged node icons
|
|
getIcons: ['validate', ({}, cbk) => getIcons({fs}, cbk)],
|
|
|
|
// Determine the public key to use
|
|
getKey: ['validate', ({}, cbk) => findKey({lnd, query}, cbk)],
|
|
|
|
// Pull out the public key from getKey result
|
|
key: ['getKey', ({getKey}, cbk) => cbk(null, getKey.public_key)],
|
|
|
|
// Get the node details
|
|
getNode: ['key', ({key}, cbk) => getNode({lnd, public_key: key}, cbk)],
|
|
|
|
// Derive the set of keys of the peers of the node
|
|
peerKeys: ['getNode', 'key', ({getNode, key}, cbk) => {
|
|
const peerKeys = getNode.channels
|
|
.filter(({policies}) => {
|
|
const enabled = policies.filter(n => n.is_disabled !== true);
|
|
|
|
return !!enabled.length;
|
|
})
|
|
.map(n => n.policies.find(n => n.public_key !== key).public_key);
|
|
|
|
return cbk(null, uniq(peerKeys));
|
|
}],
|
|
|
|
// Log the high-level node details
|
|
nodeDetails: [
|
|
'getIcons',
|
|
'getNode',
|
|
'key',
|
|
'peerKeys',
|
|
({getIcons, getNode, key, peerKeys}, cbk) =>
|
|
{
|
|
const mainIcons = getIcons.nodes.find(n => n.public_key === key);
|
|
|
|
const mainAlias = chartAliasForPeer({
|
|
alias: getNode.alias,
|
|
icons: !!mainIcons ? mainIcons.icons : undefined,
|
|
public_key: key,
|
|
});
|
|
|
|
logger.info({
|
|
id: key,
|
|
node: mainAlias.display,
|
|
capacity: displayTokens(getNode.capacity),
|
|
is_accepting_large_channels: isLarge(getNode.features) || undefined,
|
|
is_onion: isOnion(getNode.sockets) || undefined,
|
|
is_clearnet: isClear(getNode.sockets) || undefined,
|
|
is_unconnectable: !getNode.sockets.length || undefined,
|
|
peer_count: peerKeys.length,
|
|
});
|
|
|
|
return cbk();
|
|
}],
|
|
|
|
// Get distances
|
|
getDistances: ['peerKeys', ({peerKeys}, cbk) => {
|
|
// Exit early when there is no distance filter
|
|
if (!hasDistanceFilter(filters)) {
|
|
return cbk(null, peerKeys.map(destination => ({destination})));
|
|
}
|
|
|
|
return asyncMap(peerKeys, (destination, cbk) => {
|
|
return getRouteToDestination({
|
|
destination,
|
|
lnd,
|
|
is_ignoring_past_failure: true,
|
|
tokens: distanceTokens,
|
|
},
|
|
(err, res) => {
|
|
if (!!err) {
|
|
return cbk(err);
|
|
}
|
|
|
|
if (!res.route) {
|
|
return cbk(null, {destination, hops: Infinity});
|
|
}
|
|
|
|
return cbk(null, {destination, hops: --res.route.hops.length});
|
|
});
|
|
},
|
|
cbk);
|
|
}],
|
|
|
|
// Final set of peers
|
|
peers: [
|
|
'getDistances',
|
|
'getHeight',
|
|
'getIcons',
|
|
'getNode',
|
|
'peerKeys',
|
|
({getDistances, getHeight, getIcons, getNode, peerKeys}, cbk) =>
|
|
{
|
|
const sorting = sort || defaultSort;
|
|
|
|
const peers = peerKeys.map(peerKey => {
|
|
const {hops} = getDistances.find(n => n.destination === peerKey);
|
|
|
|
const capacity = getNode.channels
|
|
.filter(n => !!n.policies.find(n => n.public_key === peerKey))
|
|
.reduce((sum, {capacity}) => sum + capacity, Number());
|
|
|
|
const connectHeight = min(...getNode.channels
|
|
.filter(n => !!n.policies.find(n => n.public_key === peerKey))
|
|
.map(({id}) => decodeChanId({channel: id}).block_height));
|
|
|
|
const inPolicies = getNode.channels
|
|
.map(n => n.policies.find(n => n.public_key === peerKey))
|
|
.filter(n => !!n && n.fee_rate !== undefined);
|
|
|
|
const outPolicies = getNode.channels
|
|
.filter(n => !!n.policies.find(n => n.public_key === peerKey))
|
|
.map(n => n.policies.find(n => n.public_key !== peerKey))
|
|
.filter(n => n.fee_rate !== undefined);
|
|
|
|
const backFee = min(...inPolicies.map(n => n.inbound_rate_discount));
|
|
const inDisabled = inPolicies.filter(n => n.is_disabled === true);
|
|
const inboundFeeRate = max(...inPolicies.map(n => n.fee_rate));
|
|
const outDisabled = outPolicies.filter(n => n.is_disabled === true);
|
|
const outFeeRate = max(...outPolicies.map(n => n.fee_rate));
|
|
const rebate = min(...outPolicies.map(n => n.inbound_rate_discount));
|
|
|
|
const isInDisabled = inDisabled.length === inPolicies.length;
|
|
const isOutDisabled = outDisabled.length === outPolicies.length;
|
|
|
|
const sorts = {
|
|
capacity,
|
|
age: connectHeight,
|
|
in_fee: inboundFeeRate,
|
|
out_fee: outFeeRate,
|
|
};
|
|
|
|
// Check if the peer matches filters
|
|
const matching = isMatchingFilters({
|
|
filters,
|
|
variables: {
|
|
capacity,
|
|
hops,
|
|
age: getHeight.current_block_height - connectHeight,
|
|
height: connectHeight,
|
|
in_fee_rate: inboundFeeRate,
|
|
out_fee_rate: outFeeRate,
|
|
},
|
|
});
|
|
|
|
// Exit early when there is a filter error
|
|
if (!!matching.failure) {
|
|
return matching.failure;
|
|
}
|
|
|
|
// Exit early when the peer is not matching provided filters
|
|
if (!matching.is_matching) {
|
|
return;
|
|
}
|
|
|
|
const displayInPolicy = [
|
|
disableTag(isInDisabled),
|
|
displayFee(inPolicies, inboundFeeRate),
|
|
displayDiscount(backFee),
|
|
];
|
|
|
|
const displayOutPolicy = [
|
|
disableTag(isOutDisabled),
|
|
displayFee(outPolicies, outFeeRate),
|
|
displayDiscount(rebate),
|
|
];
|
|
|
|
const row = [
|
|
ageMs(blockTime(getHeight.current_block_height, connectHeight)),
|
|
join(displayInPolicy),
|
|
displayTokens(capacity),
|
|
join(displayOutPolicy),
|
|
peerKey,
|
|
];
|
|
|
|
if (!!hasDistanceFilter(filters)) {
|
|
row.unshift(hops);
|
|
}
|
|
|
|
return {sorts, row};
|
|
})
|
|
.filter(n => !!n);
|
|
|
|
// Exit early when there was an error in one of the filters
|
|
if (!!peers.find(n => !!n.error)) {
|
|
return cbk([500, 'FailedToParseFilter', peers.find(n => n.error)]);
|
|
}
|
|
|
|
peers.sort((a, b) => sortBy(a.sorts[sorting], b.sorts[sorting]));
|
|
|
|
return cbk(null, peers.map(n => n.row));
|
|
}],
|
|
|
|
// Get rows with peer aliases
|
|
getRowsWithAliases: ['getIcons', 'peers', ({getIcons, peers}, cbk) => {
|
|
return asyncMap(peers, (peer, cbk) => {
|
|
// The public key is the final column
|
|
const [id] = peer.slice().reverse();
|
|
|
|
const nodeIcons = getIcons.nodes.find(n => n.public_key === id);
|
|
|
|
return getNodeAlias({id, lnd}, (err, res) => {
|
|
if (!!err) {
|
|
return cbk(err);
|
|
}
|
|
|
|
const chartAlias = chartAliasForPeer({
|
|
alias: res.alias,
|
|
icons: !!nodeIcons ? nodeIcons.icons : undefined,
|
|
public_key: id,
|
|
});
|
|
|
|
return cbk(null, [chartAlias.display].concat(peer));
|
|
});
|
|
},
|
|
cbk);
|
|
}],
|
|
|
|
// Final set of rows
|
|
rows: ['getRowsWithAliases', ({getRowsWithAliases}, cbk) => {
|
|
const [titles] = header;
|
|
|
|
const headers = titles.slice();
|
|
|
|
if (!!hasDistanceFilter(filters)) {
|
|
const first = headers.shift();
|
|
|
|
headers.unshift(hopsTitle);
|
|
headers.unshift(first);
|
|
}
|
|
|
|
const rows = [].concat([headers]).concat(getRowsWithAliases);
|
|
|
|
return cbk(null, {rows});
|
|
}],
|
|
},
|
|
returnResult({reject, resolve, of: 'rows'}, cbk));
|
|
});
|
|
};
|