balanceofsatoshis
Version:
Lightning balance CLI
535 lines (461 loc) • 16.9 kB
JavaScript
const {randomBytes} = require('crypto');
const asyncAuto = require('async/auto');
const {createHodlInvoice} = require('ln-service');
const {getChainFeeRate} = require('ln-service');
const {getNodeAlias} = require('ln-sync');
const {returnResult} = require('asyncjs-util');
const {sendMessageToPeer} = require('ln-service');
const {subscribeToInvoice} = require('ln-service');
const {assumedOpenTransactionVbytes} = require('./constants');
const {codeInvalidParameters} = require('./lsps1_protocol');
const {codeOptionMismatch} = require('./lsps1_protocol');
const {defaultLifetimeBlocks} = require('./constants');
const {errMessageInvalidParams} = require('./lsps1_protocol');
const {errMessageOptionMismatch} = require('./lsps1_protocol');
const makeErrorMessage = require('./make_error_message');
const openSoldChannel = require('./open_sold_channel');
const {orderStateCreated} = require('./lsps1_protocol');
const {paymentStateExpectedPayment} = require('./lsps1_protocol');
const {typeForMessaging} = require('./lsps1_protocol');
const {versionJsonRpc} = require('./lsps1_protocol');
const blocksAsMs = blocks => blocks * 10 * 60 * 1000;
const blocksPerYear = 144 * 365;
const capacityFee = (rate, capacity) => Math.floor(rate * capacity / 1e6);
const decodeMessage = n => Buffer.from(n, 'hex').toString();
const describeBlocks = blocks => `${(blocks / 144).toFixed(2)} days`;
const encodeMessage = n => Buffer.from(n, 'utf8').toString('hex');
const expiryDate = ms => new Date(Date.now() + ms).toISOString();
const {floor} = Math;
const isNumber = n => !!n && !isNaN(n);
const makeOrderId = () => randomBytes(16).toString('hex');
const maxMessageIdLength = 100;
const niceAlias = n => `${(n.alias || n.id).trim()} ${n.id}`;
const notNegative = n => Math.max(0, n);
const orderExpiryMs = 1000 * 60 * 60;
const {parse} = JSON;
const {stringify} = JSON;
const sumOf = arr => arr.reduce((sum, n) => sum + n, 0);
const tokensAsBigUnit = tokens => (tokens / 1e8).toFixed(8);
/** Create a new order invoice for a channel open attempt sale
{
fee_rate: <Capacity Fee Rate Per Year Parts Per Million Number>
max_capacity: <Maximum Capacity Tokens Number>
message: <Received Message String>
min_capacity: <Minimum Capacity Tokens Number>
lnd: <Authenticated LND API Object>
logger: <Winston Logger Object>
orders: <Orders Map Object>
to_peer: <Client Public Key Identity Hex String>
}
@returns via cbk or Promise
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (args.fee_rate === undefined || !isNumber(args.fee_rate)) {
return cbk([400, 'ExpectedCapacityRateToProcessOpenChannelOrder']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToProcessOpenChannel']);
}
if (!args.logger) {
return cbk([400, 'ExpectedLoggerToProcessOpenChannelOrder']);
}
if (!args.max_capacity) {
return cbk([400, 'ExpectedMaxCapacityToProcessOpenChannelOrder']);
}
if (!args.min_capacity) {
return cbk([400, 'ExpectedMinCapacityToProcessOpenChannelOrder']);
}
if (!args.message) {
return cbk([400, 'ExpectedMessageToProcessOpenChannelOrder']);
}
try {
parse(decodeMessage(args.message));
} catch (e) {
return cbk([400, 'ExpectedValidMessageToProcessOpenChannelOrder']);
}
if (!args.orders) {
return cbk([400, 'ExpectedOrdersMapToProcessOpenChannelOrder']);
}
if (!args.to_peer) {
return cbk([400, 'ExpectedPubkeyToProcessOpenChannelOrder']);
}
return cbk();
},
// Get the peer alias
getAlias: ['validate', ({}, cbk) => {
return getNodeAlias({id: args.to_peer, lnd: args.lnd}, cbk);
}],
// Parse the message
message: ['validate', ({}, cbk) => {
const message = parse(decodeMessage(args.message));
// A response cannot be returned when there is no request id
if (!message.id || message.id.length > maxMessageIdLength) {
return cbk([400, 'ExpectedMessageIdToProcessOpenChannelOrder']);
}
const order = makeOrderId();
return cbk(null, {order, id: message.id, params: message.params});
}],
// Validate the message
getMessage: ['message', ({message}, cbk) => {
// Params are needed for order information
if (!message.params) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingParamsInCreateOrderRequest',
property: 'params',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
if (!isNumber(message.params.lsp_balance_sat)) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingLspBalanceInCreateOrderRequest',
property: 'lsp_balance_sat',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
if (!isNumber(message.params.client_balance_sat)) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingClientBalanceInCreateOrderRequest',
property: 'client_balance_sat',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
if (!isNumber(message.params.funding_confirms_within_blocks)) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingConfirmsWithinBlocksInCreateOrderRequest',
property: 'funding_confirms_within_blocks',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
if (!isNumber(message.params.channel_expiry_blocks)) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingChannelExpiryBlocksInCreateOrderRequest',
property: 'channel_expiry_blocks',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
if (message.params.announce_channel === undefined) {
return cbk(null, {
error: makeErrorMessage({
code: codeInvalidParameters,
data: {
message: 'MissingAnnounceChannelInCreateOrderRequest',
property: 'announce_channel',
},
id: message.id,
message: errMessageInvalidParams,
}),
});
}
const capacity = sumOf([
Number(message.params.lsp_balance_sat),
Number(message.params.client_balance_sat),
]);
if (capacity > Number(args.max_capacity)) {
return cbk(null, {
error: makeErrorMessage({
code: codeOptionMismatch,
data: {
message: 'OrderExceedingTotalCapacityInCreateOrderRequest',
property: 'lsp_balance_sat',
},
id: message.id,
message: errMessageOptionMismatch,
}),
});
}
if (capacity < Number(args.min_capacity)) {
return cbk(null, {
error: makeErrorMessage({
code: codeOptionMismatch,
data: {
message: 'OrderBelowMinCapacityInCreateOrderRequest',
property: 'lsp_balance_sat',
},
id: message.id,
message: errMessageOptionMismatch,
}),
});
}
if (!!Number(message.params.client_balance_sat)) {
return cbk(null, {
error: makeErrorMessage({
code: codeOptionMismatch,
data: {
message: 'ClientBalanceTooHighInCreateOrderRequest',
property: 'client_balance_sat',
},
id: message.id,
message: errMessageOptionMismatch,
}),
});
}
const minLifetimeBlocks = Number(message.params.channel_expiry_blocks);
if (minLifetimeBlocks > Number(defaultLifetimeBlocks)) {
return cbk(null, {
error: makeErrorMessage({
code: codeOptionMismatch,
data: {
message: 'ChannelExpiryBlocksTooHighInCreateOrderRequest',
property: 'channel_expiry_blocks',
},
id: message.id,
message: errMessageOptionMismatch,
}),
});
}
return cbk(null, {
capacity,
id: message.id,
order: message.order,
params: message.params,
});
}],
// Send error message
sendErrorMessage: ['getMessage', ({getMessage}, cbk) => {
// Exit early when the order did not error
if (!getMessage.error) {
return cbk();
}
return sendMessageToPeer({
lnd: args.lnd,
message: encodeMessage(stringify(getMessage.error)),
public_key: args.to_peer,
type: typeForMessaging,
},
cbk);
}],
// Get chain fees
getChainFees: ['getMessage', ({getMessage}, cbk) => {
// Exit early when there was an error
if (!!getMessage.error) {
return cbk();
}
// Exit early when there are no message params to process
if (!getMessage.params) {
return cbk();
}
const blocks = getMessage.params.funding_confirms_within_blocks;
return getChainFeeRate({
confirmation_target: Number(blocks),
lnd: args.lnd,
},
cbk);
}],
// Calculate fees
getFees: [
'getChainFees',
'getMessage',
({getChainFees, getMessage}, cbk) =>
{
// Exit early when there was an error
if (!!getMessage.error) {
return cbk();
}
const baseFee = notNegative(floor(args.base_fee));
const isPrivate = !getMessage.params.announce_channel;
const rate = getChainFees.tokens_per_vbyte;
const time = getMessage.params.channel_expiry_blocks / blocksPerYear;
const estimatedChainFee = floor(assumedOpenTransactionVbytes * rate);
const privateRate = isPrivate ? args.private_fee_rate : Number();
const ppmFees = floor(args.fee_rate) + floor(privateRate);
const capacityFees = capacityFee(ppmFees, getMessage.capacity);
const ppmTotalFee = Math.ceil(time * capacityFees);
args.logger.info({
request: {
capacity: tokensAsBigUnit(getMessage.capacity),
lifetime: describeBlocks(getMessage.params.channel_expiry_blocks),
ppm_fee: tokensAsBigUnit(ppmTotalFee),
base_fee: tokensAsBigUnit(baseFee),
chain_fee: tokensAsBigUnit(estimatedChainFee),
},
});
return cbk(null, {
capacity: getMessage.capacity,
fees: baseFee + notNegative(ppmTotalFee) + estimatedChainFee,
order: getMessage.order,
});
}],
// Make the invoice for this order
makeInvoice: [
'getAlias',
'getFees',
'getMessage',
({getAlias, getFees, getMessage}, cbk) =>
{
// Exit early when there was an error
if (!getFees) {
return cbk();
}
const capacity = tokensAsBigUnit(getFees.capacity);
const minLifetimeBlocks = getMessage.params.channel_expiry_blocks;
const expiry = expiryDate(blocksAsMs(minLifetimeBlocks));
args.logger.info({
capacity,
expiry,
quote: tokensAsBigUnit(getFees.fees),
returning_quote_to: niceAlias(getAlias),
});
return createHodlInvoice({
description: `Channel ${capacity} to ${expiry} (${getFees.order})`,
expires_at: expiryDate(orderExpiryMs),
lnd: args.lnd,
tokens: getFees.fees,
},
cbk);
}],
// Wait for the invoice to be paid
waitForPayment: [
'makeInvoice',
'message',
({makeInvoice, message}, cbk) =>
{
// Exit early when there was an error
if (!makeInvoice) {
return cbk();
}
const sub = subscribeToInvoice({id: makeInvoice.id, lnd: args.lnd});
// Stop listening to the invoice after it expires
const timeout = setTimeout(() => {
sub.removeAllListeners();
return cbk([408, 'TimedOutWaitingForOpenChannelLightningPayment']);
},
orderExpiryMs);
// Wait for the payment to come in
sub.on('invoice_updated', invoice => {
// Only consider updates where the payment is being held
if (!invoice.is_held) {
return;
}
clearTimeout(timeout);
sub.removeAllListeners();
return cbk(null, {
id: invoice.id,
order: message.order,
secret: makeInvoice.secret,
});
});
// Exit with error when there is a subscription failure
sub.on('error', err => {
clearTimeout(timeout);
sub.removeAllListeners();
return cbk([503, 'SubscriptionToOpenChannelInvoiceFails', {err}]);
});
}],
// Send order message
makeOrder: ['makeInvoice', 'message', ({makeInvoice, message}, cbk) => {
// Exit early when there was an error
if (!makeInvoice) {
return;
}
const confTarget = message.params.funding_confirms_within_blocks;
const requiredConfs = message.params.required_channel_confirmations;
const response = stringify({
id: message.id,
jsonrpc: versionJsonRpc,
result: {
announce_channel: !!message.params.announce_channel,
channel: null,
channel_expiry_blocks: message.params.channel_expiry_blocks,
client_balance_sat: Number().toString(),
funding_confirms_within_blocks: confTarget,
created_at: new Date().toISOString(),
lsp_balance_sat: message.params.lsp_balance_sat,
order_id: message.order,
order_state: orderStateCreated,
payment: {
bolt11: {
expires_at: expiryDate(orderExpiryMs),
fee_total_sat: makeInvoice.tokens.toString(),
invoice: makeInvoice.request,
order_total_sat: makeInvoice.tokens.toString(),
state: paymentStateExpectedPayment,
},
},
required_channel_confirmations: requiredConfs,
token: String(),
},
});
// Store the order
args.orders.set(message.order, response);
// Tell the client about the order
return sendMessageToPeer({
lnd: args.lnd,
message: encodeMessage(response),
public_key: args.to_peer,
type: typeForMessaging,
},
cbk);
}],
// Calculate open chain fees
getOpenFeeRate: ['getMessage', 'waitForPayment', ({getMessage}, cbk) => {
// Exit early when there are no message params to process
if (!getMessage.params) {
return cbk();
}
const blocks = getMessage.params.funding_confirms_within_blocks;
return getChainFeeRate({
confirmation_target: Number(blocks),
lnd: args.lnd,
},
cbk);
}],
// Open the channel
open: [
'getOpenFeeRate',
'waitForPayment',
({getOpenFeeRate, waitForPayment}, cbk) =>
{
// Exit early when there was an error
if (!waitForPayment || !waitForPayment.order) {
return cbk();
}
return openSoldChannel({
chain_fee: getOpenFeeRate.tokens_per_vbyte,
invoice_id: waitForPayment.id,
lnd: args.lnd,
logger: args.logger,
open_to: args.to_peer,
order_id: waitForPayment.order,
orders: args.orders,
secret: waitForPayment.secret,
},
cbk);
}],
},
returnResult({reject, resolve}, cbk));
});
};