balanceofsatoshis
Version:
Lightning balance CLI
530 lines (450 loc) • 16.8 kB
JavaScript
const asyncAuto = require('async/auto');
const {findKey} = require('ln-sync');
const {formatTokens} = require('ln-sync');
const {getChannels} = require('ln-service');
const {getIdentity} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getPeerLiquidity} = require('ln-sync');
const {getPrices} = require('@alexbosworth/fiat');
const {parseAmount} = require('ln-accounting');
const {parsePaymentRequest} = require('ln-service');
const {returnResult} = require('asyncjs-util');
const {getIgnores} = require('./../routing');
const getLnurlRequest = require('./../lnurl/get_lnurl_request');
const {getTags} = require('./../tags');
const parseUrl = require('./../lnurl/parse_url');
const probeDestination = require('./probe_destination');
const coins = ['BTC', 'LTC'];
const defaultFiatRateProvider = 'coingecko';
const feeTokensForFeeRate = (tokens, rate) => Math.floor(rate * tokens / 1e6);
const fiats = ['EUR', 'USD'];
const hasFiat = n => /(eur|usd)/gim.test(n);
const {isArray} = Array;
const isPublicKey = n => /^[0-9A-F]{66}$/i.test(n);
const maxQuizLength = 10;
const rateAsTokens = rate => 1e8 / rate;
const sumOf = arr => arr.reduce((sum, n) => sum + n, Number());
const {min} = Math;
const minQuiz = 2;
const minTokens = 1;
const networks = {btc: 'BTC', btctestnet: 'BTC', ltc: 'LTC'};
const quizStart = 80509;
const tokAsBigTok = tokens => !tokens ? undefined : (tokens / 1e8).toFixed(8);
const utf8AsHex = n => Buffer.from(n, 'utf8').toString('hex');
/** Push a payment to a destination
{
amount: <Amount to Push Tokens String>
avoid: [<Avoid Forwarding Through String>]
destination: <Destination Public Key Hex String>
fs: {
getFile: <Read File Contents Function> (path, cbk) => {}
}
[in_through]: <Pay In Through Peer String>
[is_dry_run]: <Do Not Push Payment Bool>
[is_omitting_message_from]: <Do Not Include From Key In Message Bool>
[is_strict_max_fee]: <Avoid Probing Too-High Fee 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>
[message]: <Message to Include With Payment String>
[out_through]: <Pay Out Through Peer String>
quiz_answers: [<Quiz Answer String>]
request: <Request Function>
[timeout_minutes]: <Stop Searching For Route After N Minutes Number>
}
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!args.amount) {
return cbk([400, 'ExpectedAmountToSendInPushPayment']);
}
if (!isArray(args.avoid)) {
return cbk([400, 'ExpectedArrayOfAvoidDirectivesToSendPushPayment']);
}
if (!args.fs) {
return cbk([400, 'ExpectedFileSystemMethodsToPushPayment']);
}
if (!!args.in_through && !!isArray(args.in_through)) {
return cbk([400, 'MultipleInboundPeersNotSupported']);
}
if (!!args.is_omitting_message_from && !args.message) {
return cbk([400, 'ExpectedMessageToSendWhenSpecifyingOmitFromKey']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToPushPayment']);
}
if (!args.logger) {
return cbk([400, 'ExpectedLoggerToPushPayment']);
}
if (args.max_fee === undefined) {
return cbk([400, 'ExpectedMaxFeeAmountToPushPayment']);
}
if (!!args.out_through && !!isArray(args.out_through)) {
return cbk([400, 'MultipleOutboundPeersNotSupported']);
}
if (!isArray(args.quiz_answers)) {
return cbk([400, 'ExpectedMultipleQuizAnswersToSend']);
}
if (!!args.quiz_answers.length && !args.message) {
return cbk([400, 'ExpectedQuizQuestionMessageToSendQuiz']);
}
if (!!args.quiz_answers.length && args.quiz_answers.length < minQuiz) {
return cbk([400, 'ExpectedMultipleQuizAnswersToSend']);
}
if (args.quiz_answers.length > maxQuizLength) {
return cbk([400, 'TooManyAnswersForQuiz', {max: maxQuizLength}]);
}
if (!args.request) {
return cbk([400, 'ExpectedRequestFunctionToPushPayment']);
}
return cbk();
},
// Get channels with the peer in order to populate liquidity
getChannels: ['validate', ({}, cbk) => {
return getChannels({lnd: args.lnd}, cbk);
}],
// Get the node identity public key
getIdentity: ['validate', ({}, cbk) => {
return getIdentity({lnd: args.lnd}, cbk);
}],
// Get network name
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Get the current price of BTCUSD
getFiatPrice: ['validate', ({}, cbk) => {
// Exit early when no fiat is referenced
if (!hasFiat(args.amount)) {
return cbk();
}
return getPrices({
from: defaultFiatRateProvider,
request: args.request,
symbols: [].concat(coins).concat(fiats),
},
cbk);
}],
// Get tags for figuring out avoid flags
getTags: ['validate', ({}, cbk) => getTags({fs: args.fs}, cbk)],
// Payment details
payment: ['validate', ({}, cbk) => {
try {
const {url} = parseUrl({url: args.destination});
return cbk(null, {lnurl: args.destination});
} catch (err) {
// Ignore errors, destination isn't a LNURL
}
try {
const details = parsePaymentRequest({request: args.destination});
const {destination, mtokens} = details;
if (!!BigInt(mtokens)) {
return cbk([400, 'ExpectedZeroAmountPayRequestToSendFunds']);
}
return cbk(null, {destination, request: args.destination});
} catch (err) {
// Ignore errors, destination isn't BOLT 11
}
// Find the key to send to
return findKey({
lnd: args.lnd,
query: args.destination,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, {destination: res.public_key, is_push: true});
});
}],
// Get ignores
getIgnores: [
'getChannels',
'getIdentity',
'getTags',
({getChannels, getIdentity, getTags}, cbk) =>
{
return getIgnores({
avoid: args.avoid,
channels: getChannels.channels,
in_through: args.in_through,
lnd: args.lnd,
logger: args.logger,
public_key: getIdentity.public_key,
tags: getTags.tags,
},
cbk);
}],
// Determine the inbound node public key
getInKey: ['getChannels', ({getChannels}, cbk) => {
// Exit early when there is no inbound constraint
if (!args.in_through) {
return cbk();
}
return findKey({
channels: getChannels.channels,
lnd: args.lnd,
query: args.in_through,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, res.public_key);
});
}],
// Determine the outbound peer public key
getOutKey: ['getChannels', ({getChannels}, cbk) => {
// Exit early when there is no outbound constraint
if (!args.out_through) {
return cbk();
}
return findKey({
channels: getChannels.channels,
lnd: args.lnd,
query: args.out_through,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, res.public_key);
});
}],
// Fiat rates
fiatRates: [
'getFiatPrice',
'getNetwork',
({getFiatPrice, getNetwork}, cbk) =>
{
// Exit early when there is no fiat
if (!getFiatPrice) {
return cbk();
}
const coin = getFiatPrice.tickers.find(({ticker}) => {
return ticker === networks[getNetwork.network];
});
const rates = fiats.map(fiat => {
const {rate} = getFiatPrice.tickers.find(n => n.ticker === fiat);
return {fiat, unit: rateAsTokens(rate) * coin.rate};
});
return cbk(null, rates);
}],
// Parse the amount specified
parseAmount: [
'fiatRates',
'getChannels',
'getNetwork',
'getOutKey',
'payment',
({fiatRates, getChannels, getNetwork, getOutKey, payment}, cbk) =>
{
// Total remote balance including pending if pending fails
const inbound = getChannels.channels
.filter(n => n.partner_public_key === payment.destination)
.reduce((sum, chan) => {
// Treat incoming payment as if they were still remote balance
const inbound = chan.pending_payments.filter(n => !n.is_outgoing);
const pending = sumOf(inbound.map(({tokens}) => tokens));
return sum + chan.remote_balance + pending;
},
Number());
// Calculate the outbound peer inbound liquidity
const outInbound = getChannels.channels
.filter(n => n.partner_public_key === getOutKey)
.reduce((sum, chan) => {
// Treat incoming payment as if they were still remote balance
const inbound = chan.pending_payments.filter(n => !n.is_outgoing);
const pending = sumOf(inbound.map(({tokens}) => tokens));
return sum + chan.remote_balance + pending;
},
Number());
// Calculate the outbound peer outbound liquidity
const outOutbound = getChannels.channels
.filter(n => n.partner_public_key === getOutKey)
.reduce((sum, chan) => {
// Treat outgoing payment as if they were still local balance
const outbound = chan.pending_payments
.filter(n => !!n.is_outgoing);
const pending = sumOf(outbound.map(({tokens}) => tokens));
return sum + chan.local_balance + pending;
},
Number());
// Total local balance including pending if pending fails
const outbound = getChannels.channels
.filter(n => n.partner_public_key === payment.destination)
.reduce((sum, chan) => {
// Treat outgoing payment as if they were still local balance
const outbound = chan.pending_payments
.filter(n => !!n.is_outgoing);
const pending = sumOf(outbound.map(({tokens}) => tokens));
return sum + chan.local_balance + pending;
},
Number());
const eur = !!fiatRates ? fiatRates.find(n => n.fiat === 'EUR') : null;
const usd = !!fiatRates ? fiatRates.find(n => n.fiat === 'USD') : null;
// Variables to use in amount
const variables = {
inbound,
outbound,
eur: !!eur ? eur.unit : undefined,
liquidity: sumOf(
getChannels.channels
.filter(n => n.partner_public_key === payment.destination)
.map(n => n.capacity)
),
out_inbound: outInbound,
out_liquidity: sumOf(
getChannels.channels
.filter(n => n.partner_public_key === getOutKey)
.map(n => n.capacity)
),
out_outbound: outOutbound,
usd: !!usd ? usd.unit : undefined,
};
try {
const {tokens} = parseAmount({variables, amount: args.amount});
// Exit early when there is no max fee rate to compute max fee for
if (args.max_fee_rate === undefined) {
return cbk(null, {tokens, max_fee: args.max_fee});
}
// Compute the max potential fee given the max fee rate constraint
const maxFeeByRate = feeTokensForFeeRate(tokens, args.max_fee_rate);
// The maximum fee to pay is the lower of the fee limit, fee by rate
return cbk(null, {tokens, max_fee: min(args.max_fee, maxFeeByRate)});
} catch (err) {
return cbk([400, 'FailedToParsePushAmount', err]);
}
}],
// Get LNURL payment request
getLnurlRequest: [
'parseAmount',
'payment',
({parseAmount, payment}, cbk) =>
{
if (parseAmount.tokens < minTokens) {
return cbk([400, 'ExpectedNonZeroAmountToPushPayment']);
}
// Exit early when there is no LNURL to send to
if (!payment.lnurl) {
return cbk(null, {});
}
return getLnurlRequest({
lnurl: payment.lnurl,
request: args.request,
tokens: parseAmount.tokens,
},
cbk);
}],
// Final payment details
send: [
'getLnurlRequest',
'parseAmount',
'payment',
({getLnurlRequest, parseAmount, payment}, cbk) =>
{
if (parseAmount.tokens < minTokens) {
return cbk([400, 'ExpectedNonZeroAmountToPushPayment']);
}
return cbk(null, {
destination: getLnurlRequest.destination || payment.destination,
is_push: payment.is_push,
max_fee: parseAmount.max_fee,
request: getLnurlRequest.request || payment.request,
tokens: !getLnurlRequest.request ? parseAmount.tokens : undefined,
});
}],
// Push the amount to the destination
push: [
'getIgnores',
'getInKey',
'getOutKey',
'send',
({getIgnores, getInKey, getOutKey, send}, cbk) =>
{
args.logger.info({
max_fee: send.max_fee,
paying: formatTokens({tokens: send.tokens}).display,
to: send.destination,
});
if (!!args.is_dry_run) {
return cbk([400, 'PushPaymentDryRun']);
}
return probeDestination({
destination: send.destination,
fs: args.fs,
ignore: getIgnores.ignore,
lnd: args.lnd,
logger: args.logger,
in_through: getInKey,
is_omitting_message_from: args.is_omitting_message_from,
is_push: send.is_push,
is_real_payment: true,
is_strict_max_fee: args.is_strict_max_fee,
max_fee: send.max_fee,
message: args.message,
messages: args.quiz_answers.map((answer, i) => ({
type: (quizStart + i).toString(),
value: utf8AsHex(answer),
})),
out_through: getOutKey,
request: send.request,
timeout_minutes: args.timeout_minutes,
tokens: send.tokens,
},
cbk);
}],
// Get adjusted outbound liquidity after push
getAdjustedOutbound: ['push', ({push}, cbk) => {
// Exit early when there was no path
if (!push.attempted_paths && !!push.is_failed) {
return cbk([503, 'NoRouteToDestination']);
}
// Exit early when the payment failed
if (!push.preimage) {
return cbk([503, 'UnexpectedSendPaymentFailure']);
}
// Exit early when there is no outbound constraint
if (!args.out_through) {
return cbk();
}
const [out] = push.relays;
return getPeerLiquidity({
lnd: args.lnd,
public_key: out,
settled: push.id,
},
cbk);
}],
// Final liquidity outcome
liquidity: [
'getAdjustedOutbound',
'push',
({getAdjustedOutbound, push}, cbk) =>
{
if (!getAdjustedOutbound) {
return cbk();
}
const [out] = push.relays;
const outOpeningIn = getAdjustedOutbound.inbound_opening;
const outOpeningOut = getAdjustedOutbound.outbound_opening;
const outPendingIn = getAdjustedOutbound.inbound_pending;
const outPendingOut = getAdjustedOutbound.outbound_pending;
args.logger.info({
liquidity_change: {
increased_inbound_on: `${getAdjustedOutbound.alias} ${out}`.trim(),
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),
},
});
return cbk();
}],
},
returnResult({reject, resolve, of: 'push'}, cbk));
});
};