balanceofsatoshis
Version:
Lightning balance CLI
455 lines (379 loc) • 14.1 kB
JavaScript
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const asyncRetry = require('async/retry');
const {createInvoice} = require('ln-service');
const {getChannels} = require('ln-service');
const {getChannel} = require('ln-service');
const {getIdentity} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getNodeAlias} = require('ln-sync');
const {parsePaymentRequest} = require('ln-service');
const qrcode = require('qrcode-terminal');
const {returnResult} = require('asyncjs-util');
const {subscribeToForwardRequests} = require('ln-service');
const getInvoiceAmount = require('./get_invoice_amount');
const signPaymentRequest = require('./sign_payment_request');
const coins = ['BTC'];
const defaultFiatRateProvider = 'coinbase';
const defaultInvoiceDescription = '';
const defaultTimeoutCheckMs = 1000 * 60 * 3;
const hoursFromNow = h => new Date(Date.now() + (h * 3600000)).toISOString();
const interval = 3000;
const {isArray} = Array;
const {isInteger} = Number;
const isNumber = n => !isNaN(n);
const mtokensAsBigUnit = n => (Number(n / BigInt(1000)) / 1e8).toFixed(8);
const parseRequest = request => parsePaymentRequest({request});
const times = 20 * 60 * 24;
const tokensAsBigUnit = tokens => (tokens / 1e8).toFixed(8);
const uniq = arr => Array.from(new Set(arr));
/** Create an invoice for a requested amount
{
amount: <Invoice Amount String>
ask: <Inquirer Function>
[description]: <Invoice Description String>
[expires_in]: <Invoice Expires In Hours Number>
[is_hinting]: <Include Private Channels Bool>
[is_including_qr]: <Include QR Code Bool>
[is_rejecting_option]: <Is Rejecting Amount Increases Bool>
[is_selecting_hops]: <Is Selecting Hops Bool>
[is_virtual]: <Is Using Virtual Channel for Invoice Bool>
lnd: <Authenticated LND API Object>
[rate_provider]: <Fiat Rate Provider String>
request: <Request Function>
}
@returns via cbk or Promise
{
[is_settled]: <Invoice Was Paid Bool>
[qr]: <QR Code String>
[request]: <BOLT 11 Payment Request String>
[tokens]: <Invoice Amount Number>
}
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!args.amount) {
return cbk([400, 'ExpectedInvoiceAmountToCreateNewInvoice']);
}
if (isNumber(args.amount) && !isInteger(Number(args.amount))) {
return cbk([400, 'ExpectedIntegerAmountToInvoice']);
}
if (!args.ask) {
return cbk([400, 'ExpectedAskFunctionToCreateNewInvoice']);
}
if (args.expires_in !== undefined && !args.expires_in) {
return cbk([400, 'ExpectedNonZeroExpirationTimeForNewInvoice']);
}
if (!!args.is_hinting && !!args.is_selecting_hops) {
return cbk([400, 'CannotUseDefaultHintsAndAlsoSelectHints']);
}
if (!!args.is_rejecting_option && !args.is_virtual) {
return cbk([501, 'RejectingAmountChangesOnlySupportedWhenVirtual']);
}
if (!!args.is_virtual && !!args.is_hinting) {
return cbk([400, 'UsingHopHintsIsUnsupportedWithVirtualChannels']);
}
if (!!args.is_virtual && !!args.is_selecting_hops) {
return cbk([400, 'ChoosingHopHintsUnsupportedWithVirtualChannels']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToCreateNewInvoice']);
}
if (!args.logger) {
return cbk([400, 'ExpectedWinstonLoggerObjectToCreateNewInvoice']);
}
if (!args.request) {
return cbk([400, 'ExpectedRequestFunctionToCreateNewInvoice']);
}
return cbk();
},
// Calculate invoice expiry date
expiresAt: ['validate', ({}, cbk) => {
// Exit early if expiry is not defined
if (!args.expires_in) {
return cbk();
}
return cbk(null, hoursFromNow(args.expires_in));
}],
// Get channels to allow for selecting individual hop hints
getChannels: ['validate', ({}, cbk) => {
// Exit early when not selecting hop hints
if (!args.is_selecting_hops) {
return cbk();
}
return getChannels({
is_active: true,
is_private: true,
lnd: args.lnd,
},
cbk);
}],
// Get node aliases for channels for selecting hop hints
getAliases: ['getChannels', ({getChannels}, cbk) => {
// Exit early when not selecting hop hints
if (!args.is_selecting_hops) {
return cbk();
}
const ids = uniq(getChannels.channels.map(n => n.partner_public_key));
return asyncMap(ids, (id, cbk) => {
return getNodeAlias({id, lnd: args.lnd}, cbk);
},
cbk);
}],
// Get network name
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Get wallet info
getId: ['validate', ({}, cbk) => getIdentity({lnd: args.lnd}, cbk)],
// Get tokens to invoice from the amount
parseAmount: ['getNetwork', ({getNetwork}, cbk) => {
return getInvoiceAmount({
amount: args.amount,
network: getNetwork.network,
provider: args.rate_provider || defaultFiatRateProvider,
request: args.request,
},
cbk);
}],
// Select hop hint channels
selectChannels: [
'getAliases',
'getChannels',
'parseAmount',
({getAliases, getChannels}, cbk) =>
{
// Exit early if not selecting channels
if (!args.is_selecting_hops) {
return cbk();
}
// Make sure there are some channels to select
if (!getChannels.channels.length) {
return cbk([400, 'NoRelevantChannelsToSelectAsHints']);
}
return args.ask({
choices: getChannels.channels.map(channel => {
const node = getAliases.find(({id}) => {
return id === channel.partner_public_key
});
const value = channel.id;
const inbound = `in: ${tokensAsBigUnit(channel.remote_balance)}`;
const outbound = `out: ${tokensAsBigUnit(channel.local_balance)}`;
return {
value,
name: `${value} ${node.alias}: ${inbound} | ${outbound}.`,
};
}),
loop: false,
message: `Channels to include as hints in the invoice?`,
name: 'id',
type: 'checkbox',
validate: input => !!input.length,
},
({id}) => cbk(null, id));
}],
// Get the policies of selected channels
getPolicies: ['selectChannels', ({selectChannels}, cbk) => {
return asyncMap(selectChannels, (channel, cbk) => {
return getChannel({id: channel, lnd: args.lnd}, (err, res) => {
// Exit early when the channel isn't found
if (isArray(err) && err.slice().shift() === 404) {
return cbk();
}
if (!!err) {
return cbk(err);
}
// Exit early when the channel policies are not defined
if (!!res.policies.find(n => n.cltv_delta === undefined)) {
return cbk();
}
return cbk(null, res);
});
},
cbk);
}],
// Create the invoice in the LND database
addInvoice: [
'expiresAt',
'getPolicies',
'parseAmount',
({expiresAt, parseAmount}, cbk) => {
// Exit with error when no amount is given
if (!!args.is_virtual && !parseAmount.tokens) {
return cbk([400, 'ExpectedNonZeroInvoiceForVirtualChannel']);
}
return createInvoice({
description: args.description || defaultInvoiceDescription,
expires_at: expiresAt,
is_including_private_channels: args.is_hinting || undefined,
lnd: args.lnd,
tokens: parseAmount.tokens,
},
cbk);
}],
// Intercept virtual invoice forwards
interceptVirtualInvoice: [
'addInvoice',
'getNetwork',
({addInvoice, getNetwork}, cbk) =>
{
// Exit early when not intercepting the virtual forward
if (!args.is_virtual) {
return cbk();
}
args.logger.info({listening_for_virtual_channel_payment: true});
const expiry = new Date(parseRequest(addInvoice.request).expires_at);
let interval;
const sub = subscribeToForwardRequests({lnd: args.lnd});
// Stop listening for the HTLC when the invoice expires
interval = setInterval(() => {
// Exit early when there is still time left until the expiry date
if (new Date() < expiry) {
return;
}
clearInterval(interval);
sub.removeAllListeners();
return cbk([408, 'TimedOutWaitingForPayment']);
},
defaultTimeoutCheckMs);
const finished = (err, res) => {
clearInterval(interval);
sub.removeAllListeners();
return cbk(err, res);
};
// Listen for an error on the requests subscription
sub.on('error', err => {
args.logger.error({err});
return finished(err);
});
// Listen for a payment to the virtual channel invoice
sub.on('forward_request', async forward => {
// Exit early and accept requests that are not for this invoice
if (forward.hash !== addInvoice.id) {
return forward.accept({});
}
// Reject too small amounts
if (BigInt(forward.mtokens) < BigInt(addInvoice.mtokens)) {
return forward.reject({});
}
// Check for optionality
if (!!args.is_rejecting_option) {
try {
const {tokens} = await getInvoiceAmount({
amount: args.amount,
network: getNetwork.network,
provider: args.rate_provider || defaultFiatRateProvider,
request: args.request,
});
// Exit early and reject when the received tokens is too low
if (tokens > addInvoice.tokens) {
args.logger.error({
rejected: true,
invoice_acceptable_amount_increased: tokens,
});
return forward.reject({});
}
} catch (err) {
args.logger.error({failed_to_get_invoice_amount: err});
return forward.reject({});
}
}
args.logger.info({
receiving_channel: forward.in_channel,
accepting_payment: true,
});
forward.settle({secret: addInvoice.secret});
// Wait until the payment is no longer pending
await asyncRetry({interval, times}, async () => {
const {channels} = await getChannels({lnd: args.lnd});
const channel = channels.find(n => n.id === forward.in_channel);
if (!channel) {
throw new Error('FailedToFindForwardChannel');
}
const pending = channel.pending_payments.find(({payment}) => {
return payment === forward.in_channel;
});
if (!!pending) {
throw new Error('PaymentIsStillPending');
}
return;
});
const got = BigInt(forward.mtokens) + BigInt(forward.fee_mtokens);
args.logger.info({received: mtokensAsBigUnit(got)});
return finished();
});
return;
}],
// Create the final signed public payment request
publicRequest: [
'addInvoice',
'expiresAt',
'getId',
'getNetwork',
'getPolicies',
'parseAmount',
({
addInvoice,
expiresAt,
getId,
getNetwork,
getPolicies,
parseAmount,
},
cbk) =>
{
// Exit early if not using custom hop hints
if (!args.is_selecting_hops && !args.is_virtual) {
return cbk(null, {
request: addInvoice.request,
tokens: addInvoice.tokens,
});
}
return signPaymentRequest({
channels: getPolicies,
cltv_delta: parseRequest(addInvoice.request).cltv_delta,
description: args.description || defaultInvoiceDescription,
destination: getId.public_key,
expires_at: expiresAt,
features: parseRequest(addInvoice.request).features,
id: addInvoice.id,
is_virtual: args.is_virtual,
lnd: args.lnd,
network: getNetwork.bitcoinjs,
payment: addInvoice.payment,
tokens: parseAmount.tokens,
virtual_fee_rate: args.virtual_fee_rate,
},
cbk);
}],
// Get a QR code for the request URL
qr: ['publicRequest', ({publicRequest}, cbk) => {
// Exit early when not including the QR code
if (!args.is_including_qr) {
return cbk();
}
const url = `LIGHTNING:${publicRequest.request.toUpperCase()}`;
return qrcode.generate(url, {small: true}, code => cbk(null, code));
}],
// Log the virtual channel payment request
logRequest: ['publicRequest', 'qr', ({publicRequest, qr}, cbk) => {
// Exit early when not using a virtual channel
if (!args.is_virtual) {
return cbk(null, {
request: publicRequest,
qr,
});
}
args.logger.info({
request: publicRequest.request,
tokens: parseRequest(publicRequest.request).tokens,
virtual_fee_rate: args.virtual_fee_rate || undefined,
qr,
});
return cbk(null, {is_settled: true});
}],
},
returnResult({reject, resolve, of: 'logRequest'}, cbk));
});
};