UNPKG

balanceofsatoshis

Version:
708 lines (590 loc) 24.1 kB
const asyncAuto = require('async/auto'); const asyncMap = require('async/map'); const asyncReflect = require('async/reflect'); const asyncUntil = require('async/until'); const {bold} = require('colorette'); const {decodeChanId} = require('bolt07'); const {formatTokens} = require('ln-sync'); const {getChannel} = require('ln-service'); const {getChannels} = require('ln-service'); const {getClosedChannels} = require('ln-service'); const {getHeight} = require('ln-service'); const {getInvoices} = require('ln-service'); const {getNetwork} = require('ln-sync'); const {getNode} = require('ln-service'); const {getPayments} = require('ln-sync'); const {getPeers} = require('ln-service'); const {getPendingChannels} = require('ln-service'); const moment = require('moment'); const {returnResult} = require('asyncjs-util'); const size = require('window-size'); const {authenticatedLnd} = require('./../lnd'); const {chartAliasForPeer} = require('./../display'); const {formatFeeRate} = require('./../display'); const {getIcons} = require('./../display'); const {getPastForwards} = require('./../routing'); const {isMatchingFilters} = require('./../display'); const {sortBy} = require('./../arrays'); const closedSorts = ['fee_earnings', 'first_connected']; const defaultInvoicesLimit = 200; const defaultSort = 'first_connected'; const estimateDiskFootprint = n => Math.round(n * 55 / 1e6 * 10) / 10; const fromNow = epoch => !epoch ? undefined : moment(epoch * 1e3).fromNow(); const hasDiskFilter = filter => /disk_usage_mb/gim.test(filter); const {isArray} = Array; const {max} = Math; const minutesPerBlock = network => network === 'ltcmainnet' ? 10 / 4 : 10; const notNull = array => array.filter(n => n !== null); const notFoundIndex = -1; const {round} = Math; const sumOf = arr => arr.reduce((sum, n) => sum + n, Number()); const sumMtokens = arr => arr.reduce((sum, n) => sum + BigInt(n), BigInt(0)); const uniq = arr => Array.from(new Set(arr)); const wideSizeCols = 150; const sumOfBig = a => a.reduce((sum, n) => sum + BigInt(n || '0'), BigInt(0)); const mtokensAsTokens = mtokens => Number(mtokens / BigInt(1000)); const isStandardMaxHtlc = (n, total) => !n || n === Math.ceil(total * 0.99); const maxPaySize = 4294967; /** Get channel-connected peers { [earnings_days]: <Routing Fee Earnings Days Number> [filters]: [<Formula Expression String>] fs: { getFile: <Read File Contents Function> (path, cbk) => {} } [idle_days]: <Not Active For Days Number> [is_active]: <Active Channels Only Bool> [is_monochrome]: <Mute Colors Bool> [is_offline]: <Offline Channels Only Bool> [is_private]: <Private Channels Only Bool> [is_public]: <Public Channels Only Bool> [is_table]: <Peers As Table Bool> lnd: <Authenticated LND gRPC API Object> omit: [<Omit Peer With Public Key Hex String>] [sort_by]: <Sort Results By Attribute String> [tags]: [<Tag Identifier String>] } @returns via cbk or Promise { peers: [{ alias: <Node Alias String> [fee_earnings]: <Fees Earned Via Peer Tokens Number> first_connected: <Oldest Channel With Peer String> [last_activity]: <Last Activity String> inbound_fee_rate: <Inbound Fee Rate String> inbound_liquidity: <Inbound Liquidity Amount Number> outbound_liquidity: <Outbound Liquidity Amount Number> public_key: <Public Key Hex String> }] } */ module.exports = (args, cbk) => { return new Promise((resolve, reject) => { return asyncAuto({ // Check arguments validate: cbk => { if (!args.fs) { return cbk([400, 'ExpectedFsToGetPeers']); } if (!args.lnd) { return cbk([400, 'ExpectedLndToGetPeers']); } if (!isArray(args.omit)) { return cbk([400, 'ExpectedOmitArrayToGetPeers']); } if (!!isArray(args.sort_by)) { return cbk([400, 'SortingByMultipleFieldsNotSupported']); } return cbk(); }, // Get node icons getIcons: ['validate', ({}, cbk) => getIcons({fs: args.fs}, cbk)], // Determine if closed channels should be included isIncludingClosed: ['validate', ({}, cbk) => { if (!!args.earnings_days || !!args.idle_days) { return cbk(null, true); } if (closedSorts.includes(args.sort_by)) { return cbk(null, true); } return cbk(null, false); }], // Get closed channels getClosed: ['isIncludingClosed', ({isIncludingClosed}, cbk) => { // Exit early when no closed channel data is needed if (!isIncludingClosed) { return cbk(null, {channels: []}); } return getClosedChannels({lnd: args.lnd}, cbk); }], // Get fee earnings getForwards: ['validate', ({}, cbk) => { const dayFilters = [args.earnings_days, args.idle_days]; // Exit early when there are no days to get forwards over if (!dayFilters.filter(n => !!n).length) { return cbk(null, {forwards: []}); } const days = max(...dayFilters.filter(n => !!n)); return getPastForwards({days, lnd: args.lnd}, cbk); }], // Get invoices getInvoices: ['validate', ({}, cbk) => { const invoices = []; let token; if (args.idle_days === undefined) { return cbk(null, invoices); } const after = moment().subtract(args.idle_days, 'days').toISOString(); return asyncUntil( cbk => cbk(null, token === false), cbk => { return getInvoices({ token, limit: !token ? defaultInvoicesLimit : undefined, lnd: args.lnd, }, (err, res) => { if (!!err) { return cbk(err); } token = res.next || false; // When there is a too-old invoice returned, stop paging if (!!after && res.invoices.find(n => n.created_at < after)) { token = false; } res.invoices.forEach(n => invoices.push(n)); return cbk(); }); }, err => { if (!!err) { return cbk(err); } return cbk(null, invoices.filter(n => !!n.is_confirmed)); } ); }], // Get the network name getNetwork: ['validate', asyncReflect(({}, cbk) => { return getNetwork({lnd: args.lnd}, cbk); })], // Get payments getPayments: ['validate', ({}, cbk) => { // Exit early and skip long payments lookup when idle days not needed if (args.idle_days === undefined) { return cbk(null, {payments: []}) } const after = moment().subtract(args.idle_days, 'days').toISOString(); return getPayments({after, lnd: args.lnd}, cbk); }], // Get connected peers getPeers: ['validate', ({}, cbk) => getPeers({lnd: args.lnd}, cbk)], // Get pending channels getPending: ['validate', ({}, cbk) => { return getPendingChannels({lnd: args.lnd}, cbk); }], // Check tags checkTags: ['getIcons', ({getIcons}, cbk) => { // Exit early when there are no tags if (!args.tags || !args.tags.length) { return cbk(); } const unknown = args.tags.filter(tag => { return !getIcons.nodes.find(node => { return node.aliases.includes(tag); }); }); if (!!unknown.length) { return cbk([400, 'UnknownTagSpecified', {unknown}]); } return cbk(); }], // Get channels getChannels: ['getIcons', ({getIcons}, cbk) => { return getChannels({ is_active: args.is_active, is_offline: args.is_offline, is_private: args.is_private, is_public: args.is_public, lnd: args.lnd, }, (err, res) => { if (!!err) { return cbk(err); } // Filter the list of channels to satisfy tag search constraints const channels = res.channels.filter(channel => { // Exit early when there are no tag constraints if (!isArray(args.tags) || !args.tags.length) { return true; } const key = channel.partner_public_key; const node = getIcons.nodes.find(n => n.public_key === key); // Exit early when there is no matching tagged node if (!node) { return false; } return !!args.tags.find(tag => node.aliases.includes(tag)); }); return cbk(null, {channels}); }); }], // Get policies getPolicies: ['getChannels', ({getChannels}, cbk) => { return asyncMap(getChannels.channels, ({id}, cbk) => { return getChannel({id, lnd: args.lnd}, (err, res) => { const [errorCode] = err || []; // Exit early when the policy is unknown if (errorCode === 404) { return cbk(); } if (!!err) { return cbk(err); } return cbk(null, {policies: res.policies}); }); }, cbk); }], // All channels allChannels: [ 'getChannels', 'getClosed', ({getChannels, getClosed}, cbk) => { const closedChannels = getClosed.channels .filter(({id}) => !!id) .map(n => ({id: n.id, key: n.partner_public_key})); const openChannels = getChannels.channels.map(channel => { return {id: channel.id, key: channel.partner_public_key}; }); return cbk(null, [].concat(closedChannels).concat(openChannels)); }], // Forwards forwards: [ 'allChannels', 'getForwards', ({allChannels, getForwards}, cbk) => { const channels = allChannels; const forwards = getForwards.forwards.map(forward => { const inKey = channels.find(n => n.id === forward.incoming_channel); const outKey = channels.find(n => n.id === forward.outgoing_channel); return { created_at: forward.created_at, fee_mtokens: forward.fee_mtokens, inbound: !!inKey ? inKey.key : null, outbound: !!outKey ? outKey.key : null, }; }); return cbk(null, forwards); }], // Peers peers: [ 'allChannels', 'forwards', 'getChannels', 'getInvoices', 'getNetwork', 'getPayments', 'getPeers', 'getPending', 'getPolicies', async ({ allChannels, forwards, getChannels, getInvoices, getNetwork, getPayments, getPeers, getPending, getPolicies, }) => { const lastForwardedPayment = forwards.reduce((sum, forward) => { [forward.inbound, forward.outbound] .filter(n => !!n) .forEach(publicKey => { const last = sum[publicKey]; // Exit early when the last forward is later than this forward if (!!last && last > forward.created_at) { return; } return sum[publicKey] = forward.created_at; }); return sum; }, {}); const lastPaidOut = getPayments.payments.reduce((sum, n) => { const [through] = n.hops; if (!!through && (!sum[through] || sum[through] < n.created_at)) { sum[through] = n.created_at; } return sum; }, {}); const lastReceivedPayment = getInvoices.reduce((sum, n) => { n.payments.forEach(payment => { const {channels} = getChannels; const channel = channels.find(n => n.id === payment.in_channel); if (!channel) { return; } const last = sum[channel.partner_public_key]; // Exit early when the last payment is later than this payment if (!!last && last > payment.confirmed_at) { return; } return sum[channel.partner_public_key] = payment.confirmed_at; }); return sum; }, {}); const {network} = getNetwork.value || {}; const peerKeys = getChannels.channels.map(n => n.partner_public_key); const wallet = await getHeight({lnd: args.lnd}); const heights = allChannels.map(({id, key}) => { return {key, height: decodeChanId({channel: id}).block_height}; }); const mpb = minutesPerBlock(network); const peers = (await asyncMap(uniq(peerKeys), async publicKey => { const forwarded = lastForwardedPayment[publicKey]; const gotLast = lastReceivedPayment[publicKey]; const peer = getPeers.peers.find(n => n.public_key === publicKey); const lastPaidThrough = lastPaidOut[publicKey]; const feeEarnings = forwards.filter(fwd => { return fwd.inbound === publicKey || fwd.outbound === publicKey; }); const channelHeights = sortBy({ array: heights.filter(({key}) => key === publicKey), attribute: 'height', }); const feeMtokens = sumMtokens(feeEarnings.map(n => n.fee_mtokens)); const [newest] = channelHeights.sorted.slice().reverse(); const [oldest] = channelHeights.sorted; const blocks = wallet.current_block_height - oldest.height; const newBlocks = wallet.current_block_height - newest.height; const active = getChannels.channels.filter(channel => { return channel.partner_public_key === publicKey; }); // A thaw channel cannot be cooperatively closed until a block height const hasThawChannel = active .map(n => n.cooperative_close_delay_height) .filter(n => !!n) .find(n => n > wallet.current_block_height); const hasHtlcChannel = active .find(n => !!n.pending_payments.length); const isHidden = !active.find(n => !n.is_private) && !!active.length; const uptime = sumOf(active .filter(n => !!n.time_online) .map(n => n.time_online)); const downtime = sumOf(active .filter(n => !!n.time_offline) .map(n => n.time_offline)); const pendingChannels = getPending.pending_channels.filter(chan => { return !!chan.is_opening && chan.partner_public_key === publicKey; }); const channels = [].concat(active).concat(pendingChannels); const policies = getPolicies .filter(n => !!n) .map(n => n.policies.find(n => n.public_key === publicKey)) .filter(n => !!n); const feeRates = policies .map(n => n.fee_rate) .filter(n => n !== undefined); const disabled = policies.map(n => !!n.is_disabled).filter(n => !!n); const feeRate = !feeRates.length ? undefined : max(...feeRates); const maxHtlcSizes = policies.map(n => n.max_htlc_mtokens); const pastStates = sumOf(active.map(n => n.past_states)); const totalCapacity = sumOf(active.map(n => n.capacity)); const totalMaxHtlc = mtokensAsTokens(sumOfBig(maxHtlcSizes)); const isCustomMax = !isStandardMaxHtlc(totalMaxHtlc, totalCapacity); const isSmallMaxHtlc = !!isCustomMax && totalMaxHtlc < maxPaySize; let node = {alias: String(), public_key: publicKey}; // Check if the peer matches filters const matching = isMatchingFilters({ filters: args.filters || [], variables: { age: blocks, capacity: totalCapacity, blocks_since_last_channel: newBlocks, disk_usage_mb: estimateDiskFootprint(pastStates), fee_earnings: mtokensAsTokens(feeMtokens), inbound_fee_rate: feeRate, inbound_liquidity: sumOf(channels.map(n => n.remote_balance)), outbound_liquidity: sumOf(channels.map(n => n.local_balance)), }, }); // Exit early when there is a filter error if (!!matching.failure) { throw new Error(matching.failure.error); } // Exit early when the peer is not matching provided filters if (!matching.is_matching) { return; } const lastActivity = max(...[ moment().subtract(blocks * mpb, 'minutes').unix(), moment().subtract(newBlocks * mpb, 'minutes').unix(), !gotLast ? Number() : moment(gotLast).unix(), !forwarded ? Number() : moment(forwarded).unix(), !lastPaidThrough ? Number() : moment(lastPaidThrough).unix(), ].filter(n => !!n)); try { node = await getNode({ is_omitting_channels: true, lnd: args.lnd, public_key: publicKey, }); } catch (err) {} return { alias: node.alias, downtime_percentage: round(100 * (downtime / (downtime + uptime))), est_disk_usage_mb: estimateDiskFootprint(pastStates), fee_earnings: mtokensAsTokens(feeMtokens), first_connected: moment().subtract(blocks * mpb, 'minutes').unix(), inbound_fee_rate: feeRate, inbound_liquidity: sumOf(channels.map(n => n.remote_balance)), is_forwarding: hasHtlcChannel || undefined, is_inbound_disabled: !!disabled.length || undefined, is_offline: !peer || undefined, is_pending: !!pendingChannels.length || undefined, is_private: isHidden || undefined, is_small_max_htlc: isSmallMaxHtlc || undefined, is_thawing: hasThawChannel || undefined, last_activity: args.idle_days !== undefined ? lastActivity : null, outbound_liquidity: sumOf(channels.map(n => n.local_balance)), public_key: publicKey, }; })).filter(n => !!n); return { peers: sortBy({array: peers, attribute: args.sort_by || defaultSort}) .sorted .filter(n => args.omit.indexOf(n.public_key) === notFoundIndex) .filter(n => { // Always return peer when no idle days are specified if (!args.idle_days) { return true; } const hasPendingChan = getPending.pending_channels.find(chan => { return chan.partner_public_key === n.public_key; }); if (!!hasPendingChan) { return false; } const after = moment().subtract(args.idle_days, 'days').unix(); if (n.last_activity > after) { return false; } return true; }) .map(peer => { const rate = peer.inbound_fee_rate; return { alias: peer.alias, downtime_percentage: peer.downtime_percentage, est_disk_usage_mb: peer.est_disk_usage_mb, fee_earnings: peer.fee_earnings, first_connected: fromNow(peer.first_connected), last_activity: fromNow(peer.last_activity), inbound_fee_rate: formatFeeRate({rate}).display, inbound_liquidity: peer.inbound_liquidity, is_forwarding: peer.is_forwarding, is_inbound_disabled: peer.is_inbound_disabled, is_offline: peer.is_offline, is_pending: peer.is_pending, is_private: peer.is_private, is_small_max_htlc: peer.is_small_max_htlc || undefined, is_thawing: peer.is_thawing, outbound_liquidity: peer.outbound_liquidity, public_key: peer.public_key, }; }), }; }], // Final peers and table allPeers: ['getIcons', 'peers', ({getIcons, peers}, cbk) => { if (!args.is_table) { return cbk(null, { peers: peers.peers.map(n => ({ alias: n.alias, est_disk_usage_mb: n.est_disk_usage_mb || undefined, fee_earnings: n.fee_earnings || undefined, downtime_percentage: n.downtime_percentage || undefined, first_connected: n.first_connected || undefined, last_activity: n.last_activity || undefined, inbound_fee_rate: n.inbound_fee_rate || undefined, inbound_liquidity: n.inbound_liquidity || undefined, is_forwarding: n.is_forwarding || undefined, is_inbound_disabled: n.is_inbound_disabled || undefined, is_offline: n.is_offline || undefined, is_pending: n.is_pending || undefined, is_private: n.is_private || undefined, is_small_max_htlc: n.is_small_max_htlc || undefined, is_thawing: n.is_thawing || undefined, outbound_liquidity: n.outbound_liquidity || undefined, public_key: n.public_key || undefined, })), }); } const isDiskFilter = (args.filters || []).find(n => hasDiskFilter(n)); const isDiskSort = args.sort_by === 'est_disk_usage_mb'; const isWideSize = !size || size.get().width > wideSizeCols; const isShowingDisk = isDiskFilter || isDiskSort; return cbk(null, { peers: peers.peers, rows: [] .concat([notNull([ 'Alias', !!isShowingDisk ? 'Disk Mb' : null, 'Inbound', 'In Fee', 'Outbound', !!args.earnings_days ? 'Earned' : null, !!isWideSize ? 'Public Key' : null, ]).map(n => !args.is_monochrome ? bold(n) : n)]) .concat(peers.peers.map(peer => { const earnings = formatTokens({ is_monochrome: args.is_monochrome, tokens: peer.fee_earnings, }); const inbound = formatTokens({ is_monochrome: args.is_monochrome, tokens: peer.inbound_liquidity, }); const outbound = formatTokens({ is_monochrome: args.is_monochrome, tokens: peer.outbound_liquidity, }); const nodeIcons = getIcons.nodes .find(n => n.public_key === peer.public_key); const alias = chartAliasForPeer({ alias: peer.alias, downtime_percentage: peer.downtime_percentage, icons: !!nodeIcons ? nodeIcons.icons : undefined, is_forwarding: peer.is_forwarding, is_inactive: peer.is_offline, is_inbound_disabled: peer.is_inbound_disabled, is_pending: peer.is_pending, is_private: peer.is_private, is_small_max_htlc: peer.is_small_max_htlc, is_thawing: peer.is_thawing, public_key: peer.public_key, }); return notNull([ alias.display, !!isShowingDisk ? peer.est_disk_usage_mb || ' ' : null, inbound.display || ' ', peer.inbound_fee_rate || ' ', outbound.display || ' ', !!args.earnings_days ? earnings.display : null, !!isWideSize ? peer.public_key : null, ]); })), }); }], }, returnResult({reject, resolve, of: 'allPeers'}, cbk)); }); };