UNPKG

balanceofsatoshis

Version:
935 lines (805 loc) 28.9 kB
const asyncAuto = require('async/auto'); const asyncMap = require('async/map'); const asyncMapSeries = require('async/mapSeries'); const asyncRetry = require('async/retry'); const {createInvoice} = require('ln-service'); const {findKey} = require('ln-sync'); const {getChannel} = require('ln-service'); const {getChannels} = require('ln-service'); const {getHeight} = require('ln-service'); const {getIdentity} = require('ln-service'); const {getNode} = require('ln-service'); const {getPeerLiquidity} = require('ln-sync'); const {getWalletVersion} = require('ln-service'); const moment = require('moment'); const {parseAmount} = require('ln-accounting'); const {payViaRoutes} = require('ln-service'); const {returnResult} = require('asyncjs-util'); const {routeFromChannels} = require('ln-service'); const {findTagMatch} = require('./../peers'); const {formatFeeRate} = require('./../display'); const {getIgnores} = require('./../routing'); const {homePath} = require('../storage'); const {probeDestination} = require('./../network'); const {sortBy} = require('./../arrays'); const asRate = (fee, tokens) => ({rate: Math.ceil(fee / tokens * 1e6)}); const {ceil} = Math; const channelMatch = /^\d*x\d*x\d*$/; const cltvDelta = 144; const defaultCltvDelta = 40; const defaultMaxFee = 1337; const defaultMaxFeeRate = 250; const defaultMaxFeeTotal = Math.floor(5e6 * 0.0025); const feeFromRate = (mtok, rate) => BigInt(mtok) * BigInt(rate) / BigInt(1e6); const flatten = arr => [].concat(...arr); const highInbound = 4500000; const initialProbeTokens = size => Math.round((Math.random() * size) + size); const interval = 1000 * 10; const {isArray} = Array; const isPublicKey = n => /^[0-9A-F]{66}$/i.test(n); const legacyMaxRebalanceTokens = 4294967; const {max} = Math; const maxPaymentSize = 4294967; const {min} = Math; const minInboundBalance = 4294967 * 2; const minRebalanceAmount = 5e4; const minRemoteBalance = 4294967; const minTokens = 0; const minimalRebalanceAmount = 2e5; const mtokensPerToken = BigInt(1e3); const notFoundIndex = -1; const {parse} = JSON; const probeSizeMinimal = 1e2; const probeSizeRegular = 2e5 const pubKeyHexLength = 66; const rateDivisor = 1e6; const {round} = Math; const sample = a => !!a.length ? a[Math.floor(Math.random()*a.length)] : null; const sumOf = arr => arr.reduce((sum, n) => sum + n); const tagFilePath = () => homePath({file: 'tags.json'}).path; const times = 6; const tokAsBigTok = tokens => !tokens ? undefined : (tokens / 1e8).toFixed(8); const tokensAsMillitokens = tok => (BigInt(tok) * BigInt(1e3)).toString(); const topOf = arr => arr.slice(0, Math.ceil(arr.length / 2)); const uniq = arr => Array.from(new Set(arr)); /** Rebalance funds between peers { [avoid]: [<Avoid Forwarding Through Node With Public Key Hex String>] fs: { getFile: <Read File Contents Function> (path, cbk) => {} } [in_filters]: [<Inbound Filter Formula String>] [in_outbound]: <Inbound Target Outbound Liquidity Tokens Number> [in_through]: <Pay In Through Peer String> [is_strict_max_fee_rate]: <Avoid Probing Too-High Fee Rate Routes Bool> lnd: <Authenticated LND API Object> logger: <Winston Logger Object> [max_fee]: <Maximum Fee Tokens Number> [max_fee_rate]: <Max Fee Rate Tokens Per Million Number> [max_rebalance]: <Maximum Amount to Rebalance Tokens String> [node]: <Node Name String> [out_filters]: [<Outbound Filter Formula String>] [out_inbound]: <Outbound Target Inbound Liquidity Tokens Number> [out_through]: <Pay Out Through Peer String> [start]: <ISO 8601 Start Date String> [timeout_minutes]: <Deadline To Stop Rebalance Minutes Number> } @returns via cbk or Promise */ module.exports = (args, cbk) => { return new Promise((resolve, reject) => { return asyncAuto({ // Check arguments validate: cbk => { if (!args.fs) { return cbk([400, 'ExpectedFsToRebalance']); } if (!args.logger) { return cbk([400, 'ExpectedLoggerToRebalance']) } if (isArray(args.in_through)) { return cbk([400, 'MultipleInPeersIsNotSupported']); } if (!args.lnd) { return cbk([400, 'ExpectedLndToExecuteRebalance']); } if (args.max_fee === 0) { return cbk([400, 'ExpectedNonZeroMaxFeeForRebalance']); } if (args.max_fee_rate === 0) { return cbk([400, 'ExpectedNonZeroMaxFeeRateForRebalance']); } if (isArray(args.out_through)) { return cbk([400, 'MultipleOutPeersIsNotSupported']); } return cbk(); }, // Determine the maximum rebalance maximum: ['validate', ({}, cbk) => { // Exit early when there is no maximum rebalance target if (!args.max_rebalance) { return cbk(); } try { const {tokens} = parseAmount({amount: args.max_rebalance}); if (tokens < minRebalanceAmount) { return cbk([400, 'LowRebalanceAmount', {min: minRebalanceAmount}]); } return cbk(null, tokens); } catch (err) { return cbk([400, err.message]); } }], // Starting tokens to rebalance tokens: ['maximum', ({maximum}, cbk) => { if (!!maximum && maximum < minRebalanceAmount) { return cbk(null, initialProbeTokens(probeSizeMinimal)); } return cbk(null, initialProbeTokens(probeSizeRegular)); }], // Lnd by itself lnd: ['validate', ({}, cbk) => cbk(null, args.lnd)], // Get the set of tags getTags: ['validate', ({}, cbk) => { const defaultTags = {tags: []}; return args.fs.getFile(tagFilePath(), (err, res) => { if (!!err || !res) { return cbk(null, defaultTags); } try { const {tags} = parse(res.toString()); return cbk(null, {tags}); } catch (err) { return cbk(null, defaultTags); } }); }], // Get initial liquidity getInitialLiquidity: ['lnd', ({lnd}, cbk) => getChannels({lnd}, cbk)], // Get public key getPublicKey: ['lnd', ({lnd}, cbk) => getIdentity({lnd}, cbk)], // Figure out which public keys and channels to avoid ignore: [ 'getInitialLiquidity', 'getPublicKey', 'getTags', 'lnd', ({getInitialLiquidity, getPublicKey, getTags, lnd}, cbk) => { return getIgnores({ lnd, avoid: args.avoid, channels: getInitialLiquidity.channels, in_through: args.in_through, logger: args.logger, public_key: getPublicKey.public_key, tags: getTags.tags, }, (err, res) => { if (!!err) { return cbk(err); } return cbk(null, res.ignore); }); }], // Get fee rates getFees: ['getPublicKey', 'lnd', ({getPublicKey, lnd}, cbk) => { return getNode({lnd, public_key: getPublicKey.public_key}, cbk); }], // Find inbound tag public key findInTag: [ 'getFees', 'getInitialLiquidity', 'getPublicKey', 'getTags', ({getFees, getInitialLiquidity, getPublicKey, getTags}, cbk) => { const id = getPublicKey.public_key; const {failure, match, matches} = findTagMatch({ channels: getInitialLiquidity.channels.filter(n => n.is_active), filters: args.in_filters, policies: getFees.channels.map(channel => { const policy = channel.policies.find(n => n.public_key !== id); return { base_fee_mtokens: policy.base_fee_mtokens, fee_rate: policy.fee_rate, is_disabled: policy.is_disabled, public_key: policy.public_key, }; }), tags: getTags.tags, query: args.in_through, }); // Exit early when there is a filter error if (!!failure) { return cbk([400, 'FailedToParseFilter', failure]); } if (!!matches) { return cbk([400, 'MultipleTagMatchesFoundForInPeer', {matches}]); } if (!match && !!args.in_filters && !!args.in_filters.length) { return cbk([400, 'NoPeerMatchesFoundToSatisfyInboundFilter']); } return cbk(null, match); }], // Find inbound peer key if a name is specified findInKey: [ 'findInTag', 'getInitialLiquidity', 'lnd', ({findInTag, getInitialLiquidity, lnd}, cbk) => { if (!!findInTag) { return cbk(null, findInTag); } return findKey({ lnd, channels: getInitialLiquidity.channels, query: args.in_through, }, (err, res) => { if (!!err) { return cbk(err); } return cbk(null, res.public_key); }); }], // Find outbound peer key if a name is specified findOutKey: [ 'findInTag', 'getFees', 'getInitialLiquidity', 'getPublicKey', 'getTags', 'lnd', ({ findInTag, getFees, getInitialLiquidity, getPublicKey, getTags, lnd, }, cbk) => { const id = getPublicKey.public_key; const {failure, match, matches} = findTagMatch({ channels: getInitialLiquidity.channels.filter(n => n.is_active), filters: args.out_filters, policies: getFees.channels.map(channel => { const policy = channel.policies.find(n => n.public_key !== id); return { base_fee_mtokens: policy.base_fee_mtokens, fee_rate: policy.fee_rate, public_key: policy.public_key, }; }), tags: getTags.tags, query: args.out_through, }); // Exit early when there is a filter error if (!!failure) { return cbk([400, 'FailedToParseFilter', failure]); } if (!!matches) { return cbk([400, 'MultipleTagMatchesFoundForOutPeer', {matches}]); } if (match) { return cbk(null, match); } if (!match && !!args.out_filters && !!args.out_filters.length) { return cbk([400, 'NoPeerMatchesFoundToSatisfyOutboundFilter']); } return findKey({ lnd, channels: getInitialLiquidity.channels, query: args.out_through, }, (err, res) => { if (!!err) { return cbk(err); } return cbk(null, res.public_key); }); }], // Get outbound node details getOutbound: [ 'findInKey', 'findOutKey', 'getInitialLiquidity', 'ignore', 'lnd', 'tokens', ({ findInKey, findOutKey, getInitialLiquidity, ignore, lnd, tokens, }, cbk) => { const ban = ignore.filter(n => !n.channel).map(n => n.from_public_key); const active = getInitialLiquidity.channels .filter(n => !!n.is_active) .filter(n => !ban.includes(n.partner_public_key)); const channels = active .filter(n => n.partner_public_key !== findInKey) .filter(n => n.local_balance - n.local_reserve > tokens) .map(channel => { const remote = active .filter(n => n.partner_public_key === channel.partner_public_key) .reduce((sum, n) => sum + n.remote_balance, minTokens); return {remote, partner_public_key: channel.partner_public_key}; }) .filter(n => n.remote < minRemoteBalance); // Exit early with error when an outbound channel cannot be guessed if (!args.out_through && !channels.length) { return cbk([400, 'NoOutboundChannelNeedsRebalance']); } const {sorted} = sortBy({array: channels, attribute: 'remote'}); const key = findOutKey || sample(sorted).partner_public_key; const currentInbound = active .filter(n => n.partner_public_key === key) .map(n => n.remote_balance) .reduce((sum, n) => sum + n, minTokens); return getNode({ lnd, is_omitting_channels: true, public_key: key, }, (err, res) => { return cbk(null, { alias: !!res && !!res.alias ? `${res.alias} ${key}` : key, public_key: key, }); }); }], // Get inbound node details getInbound: [ 'findInKey', 'getFees', 'getInitialLiquidity', 'getOutbound', 'ignore', 'lnd', ({ findInKey, getFees, getInitialLiquidity, getOutbound, ignore, lnd, }, cbk) => { const ban = ignore.filter(n => !n.channel).map(n => n.from_public_key); const hasInThrough = !!args.in_through; const activeChannels = getInitialLiquidity.channels .filter(n => !!n.is_active) .filter(n => n.partner_public_key !== getOutbound.public_key) .filter(n => !ban.includes(n.partner_public_key)); // Sum up the remote balance of active channels const remote = activeChannels.reduce((sum, channel) => { const key = channel.partner_public_key; sum[key] = sum[key] || Number(); sum[key] = sum[key] + channel.remote_balance; return sum; }, {}); const channels = activeChannels .filter(channel => { const key = channel.partner_public_key; return hasInThrough || remote[key] > minInboundBalance; }) .map(channel => { const remote = activeChannels .filter(n => n.partner_public_key === channel.partner_public_key) .reduce((sum, n) => sum + n.remote_balance, minTokens); return { remote, id: channel.id, partner_public_key: channel.partner_public_key, }; }); if (!channels.length) { return cbk([400, 'NoInboundChannelIsAvailableToReceiveRebalance']); } // Filter out channels where the inbound fee rate is too expensive const array = channels.filter(chan => { const peerKey = chan.partner_public_key; const policies = getFees.channels.map(({policies}) => { return policies.find(n => n.public_key === peerKey); }); const feeRates = policies.filter(n => !!n).map(n => n.fee_rate); const feeRate = max(...feeRates); return feeRate < (args.max_fee_rate || defaultMaxFeeRate); }); // Exit early when there is no obvious inbound peer if (!findInKey && !array.length) { return cbk([400, 'NoHighInboundLowFeeChannelToReceiveRebalance']); } const {sorted} = sortBy({array, attribute: 'remote'}); const suggestedInbound = sample(topOf(sorted.slice().reverse())); const key = findInKey || suggestedInbound.partner_public_key; return getNode({ lnd, is_omitting_channels: true, public_key: key, }, (err, res) => { return cbk(null, { alias: !!res && !!res.alias ? `${res.alias} ${key}` : key, public_key: key, }); }); }], // Calculate maximum amount to rebalance max: [ 'getInbound', 'getOutbound', 'maximum', 'tokens', ({getInbound, getOutbound, maximum, tokens}, cbk) => { if (!!maximum && !!args.in_outbound) { return cbk([400, 'CannotSpecifyBothDiscreteAmountAndTargetAmounts']); } if (!!maximum && !!args.out_inbound) { return cbk([400, 'CannotSpecifyBothDiscreteAmountAndTargetAmounts']); } if (!!args.in_outbound && !!args.out_inbound) { return cbk([400, 'TargetingBothInAndOutAmountsNotSupported']); } if (!args.in_outbound && !args.out_inbound) { return cbk(null, maximum || maxPaymentSize); } const amount = !args.in_outbound ? args.out_inbound : args.in_outbound; const peer = !args.in_outbound ? getOutbound : getInbound; return getPeerLiquidity({ lnd: args.lnd, public_key: peer.public_key, }, (err, res) => { if (!!err) { return cbk(err); } const variables = {capacity: sumOf([res.inbound, res.outbound])}; try { parseAmount({amount, variables}); } catch (err) { return cbk([400, err.message]); } const target = parseAmount({amount, variables}).tokens; const current = !args.in_outbound ? res.inbound : res.outbound; if (current > target - tokens) { return cbk([400, 'AlreadyEnoughLiquidityForPeer']); } return cbk(null, target - current); }); }], // Calculate a max fee to use in the intial probe maxFeeMtokens: ['tokens', ({tokens}, cbk) => { // Exit early when there is no strict max fee if (!args.is_strict_max_fee_rate) { return cbk(null, tokensAsMillitokens(defaultMaxFeeTotal)); } // Exit early with error when there is no fee rate to use if (args.max_fee_rate === undefined) { return cbk([400, 'ExpectedMaxFeeRateToUseStrictly']); } // Convert the initial amount to probe to mtokens const mtokens = tokensAsMillitokens(tokens); return cbk(null, feeFromRate(mtokens, args.max_fee_rate).toString()); }], // Find a route to the destination findRoute: [ 'findInKey', 'findOutKey', 'getInbound', 'getOutbound', 'getPublicKey', 'ignore', 'max', 'maxFeeMtokens', 'tokens', ({ findInKey, findOutKey, getInbound, getOutbound, getPublicKey, ignore, max, maxFeeMtokens, tokens, }, cbk) => { args.logger.info({ outgoing_peer_to_increase_inbound: getOutbound.alias, incoming_peer_to_decrease_inbound: getInbound.alias, rebalance_target_amount: tokAsBigTok(max), }); const immediateIgnore = [{ from_public_key: getPublicKey.public_key, to_public_key: getInbound.public_key, }]; if (getInbound.public_key === getOutbound.public_key) { return cbk([400, 'ExpectedDifferentPeersForInboundAndOutbound']); } return probeDestination({ tokens, destination: getPublicKey.public_key, find_max: max, fs: args.fs, ignore: [].concat(immediateIgnore).concat(ignore).filter(n => { if (!!n.to_public_key) { return true; } // Never generally avoid directly specified keys return ![findInKey, findOutKey].includes(n.from_public_key); }), in_through: getInbound.public_key, is_strict_max_fee: args.is_strict_max_fee_rate || undefined, logger: args.logger, lnd: args.lnd, max_fee_mtokens: maxFeeMtokens, out_through: getOutbound.public_key, timeout_minutes: args.timeout_minutes, }, cbk); }], // Get channels for the rebalance route channels: [ 'findRoute', 'getInitialLiquidity', 'getPublicKey', 'lnd', ({findRoute, getPublicKey, lnd}, cbk) => { if (!findRoute.success) { return cbk([400, 'FailedToFindPathBetweenPeers']); } let from = getPublicKey.public_key; return asyncMapSeries(findRoute.success, (id, cbk) => { return getChannel({id, lnd}, (err, channel) => { if (!!err) { return cbk(err); } const {capacity} = channel; const {policies} = channel; const to = policies.find(n => n.public_key !== from).public_key; // The next hop from will be this hop's to from = to; return cbk(null, {capacity, id, policies, destination: to}); }); }, cbk); }], // Create local invoice invoice: ['channels', 'findRoute', 'lnd', ({findRoute, lnd}, cbk) => { return createInvoice({ lnd, cltv_delta: defaultCltvDelta, description: 'Rebalance', tokens: findRoute.route_maximum, }, cbk); }], // Get the current height getHeight: ['channels', 'lnd', ({lnd}, cbk) => getHeight({lnd}, cbk)], // Calculate route for rebalance routes: [ 'channels', 'getHeight', 'getPublicKey', 'invoice', 'tokens', ({ channels, getHeight, getPublicKey, invoice, tokens, }, cbk) => { try { const {route} = routeFromChannels({ channels, cltv_delta: cltvDelta, destination: getPublicKey.public_key, height: getHeight.current_block_height, mtokens: (BigInt(invoice.tokens) * mtokensPerToken).toString(), payment: invoice.payment, total_mtokens: !!invoice.payment ? invoice.mtokens : undefined, }); const maxFee = args.max_fee || defaultMaxFee; const maxFeeRate = args.max_fee_rate || defaultMaxFeeRate; if (route.tokens < minRebalanceAmount) { return cbk([503, 'EncounteredUnexpectedRouteLiquidityFailure']); } const [highFeeAt] = sortBy({ array: route.hops.map(hop => ({ to: hop.public_key, fee: hop.fee, })), attribute: 'fee', }).sorted.reverse().map(n => n.to); // Exit early when a max fee is specified and exceeded if (!!maxFee && route.fee > maxFee) { return cbk([ 400, 'RebalanceTotalFeeTooHigh', {needed_max_fee: route.fee.toString(), high_fee: highFeeAt}, ]); } const feeRate = ceil(route.fee / route.tokens * rateDivisor); // Exit early when the max fee rate is specified and exceeded if (!!maxFeeRate && feeRate > maxFeeRate) { return cbk([ 400, 'RebalanceFeeRateTooHigh', {needed_max_fee_rate: feeRate.toString(), high_fee: highFeeAt}, ]); } return cbk(null, [route]); } catch (err) { return cbk([500, 'FailedToConstructRebalanceRoute', {err}]); } }], // Calculate the total inbound fee discount discount: [ 'channels', 'getHeight', 'getPublicKey', 'invoice', 'routes', ({ channels, getHeight, getPublicKey, invoice, routes, tokens, }, cbk) => { const {route} = routeFromChannels({ channels: channels.map(channel => ({ capacity: channel.capacity, destination: channel.destination, id: channel.id, policies: channel.policies.map(policy => ({ base_fee_mtokens: policy.base_fee_mtokens, cltv_delta: policy.cltv_delta, fee_rate: policy.fee_rate, is_disabled: policy.is_disabled, max_htlc_mtokens: policy.max_htlc_mtokens, min_htlc_mtokens: policy.min_htlc_mtokens, public_key: policy.public_key, })), })), cltv_delta: cltvDelta, destination: getPublicKey.public_key, height: getHeight.current_block_height, mtokens: (BigInt(invoice.tokens) * mtokensPerToken).toString(), payment: invoice.payment, total_mtokens: !!invoice.payment ? invoice.mtokens : undefined, }); const [discounted] = routes; const dif = BigInt(route.fee_mtokens) - BigInt(discounted.fee_mtokens); return cbk(null, { lowered: tokAsBigTok(Number(dif / mtokensPerToken)), route: discounted, }); }], // Execute the rebalance pay: ['discount', 'invoice', 'lnd', ({discount, invoice, lnd}, cbk) => { const routes = [discount.route]; return payViaRoutes({lnd, routes, id: invoice.id}, (err, res) => { if (!!err) { return cbk([503, 'UnexpectedErrExecutingRebalance', {err}]); } return cbk(null, { fee: res.fee, id: invoice.id, tokens: res.tokens, }); }); }], // Calculate rebalance time time: ['pay', ({}, cbk) => { // Exit early when no start time was specified if (!args.start) { return cbk(); } const end = new moment(); const start = new moment(new Date(args.start)); return cbk(null, moment.duration(end.diff(start)).humanize()); }], // Get adjusted inbound liquidity after rebalance getAdjustedInbound: ['getInbound', 'pay', ({getInbound, pay}, cbk) => { return getPeerLiquidity({ lnd: args.lnd, public_key: getInbound.public_key, settled: pay.id, }, cbk); }], // Get adjusted outbound liquidity after rebalance getAdjustedOutbound: ['getOutbound', 'pay', ({getOutbound, pay}, cbk) => { return getPeerLiquidity({ lnd: args.lnd, public_key: getOutbound.public_key, settled: pay.id, }, cbk); }], // Final rebalancing outcome rebalance: [ 'discount', 'getAdjustedInbound', 'getAdjustedOutbound', 'getInbound', 'getOutbound', 'pay', 'time', ({ discount, getAdjustedInbound, getAdjustedOutbound, getInbound, getOutbound, pay, time, }, cbk) => { const feeRate = formatFeeRate(asRate(pay.fee, pay.tokens)).display; const inOn = getAdjustedOutbound.alias || getOutbound.public_key; const inOpeningIn = getAdjustedInbound.inbound_opening; const inOpeningOut = getAdjustedInbound.outbound_opening; const inPendingIn = getAdjustedInbound.inbound_pending; const inPendingOut = getAdjustedInbound.outbound_pending; const outOn = getAdjustedInbound.alias || getInbound.public_key; const outOpeningIn = getAdjustedOutbound.inbound_opening; const outOpeningOut = getAdjustedOutbound.outbound_opening; const outPendingIn = getAdjustedOutbound.inbound_pending; const outPendingOut = getAdjustedOutbound.outbound_pending; return cbk(null, { got_inbound_fee_discount: discount.lowered, total_execution_time: time, rebalance: [ { increased_inbound_on: inOn, liquidity_inbound: tokAsBigTok(getAdjustedOutbound.inbound), liquidity_inbound_opening: tokAsBigTok(outOpeningIn), liquidity_inbound_pending: tokAsBigTok(outPendingIn), liquidity_outbound: tokAsBigTok(getAdjustedOutbound.outbound), liquidity_outbound_opening: tokAsBigTok(outOpeningOut), liquidity_outbound_pending: tokAsBigTok(outPendingOut), }, { decreased_inbound_on: outOn, liquidity_inbound: tokAsBigTok(getAdjustedInbound.inbound), liquidity_inbound_opening: tokAsBigTok(inOpeningIn), liquidity_inbound_pending: tokAsBigTok(inPendingIn), liquidity_outbound: tokAsBigTok(getAdjustedInbound.outbound), liquidity_outbound_opening: tokAsBigTok(inOpeningOut), liquidity_outbound_pending: tokAsBigTok(inPendingOut), }, { rebalanced: tokAsBigTok(pay.tokens), rebalance_fees_spent: tokAsBigTok(pay.fee), rebalance_fee_rate: feeRate, }, ], }); }], }, returnResult({reject, resolve, of: 'rebalance'}, cbk)); }); };