UNPKG

balanceofsatoshis

Version:
452 lines (368 loc) 13.9 kB
const asyncAuto = require('async/auto'); const {connectPeer} = require('ln-sync'); const {formatTokens} = require('ln-sync'); const {getIdentity} = require('ln-service'); const {getNetworkGraph} = require('ln-service'); const {getNodeAlias} = require('ln-sync'); const {parsePaymentRequest} = require('ln-service'); const {returnResult} = require('asyncjs-util'); const {defaultChannelActiveConfs} = require('./constants'); const {defaultLifetimeDays} = require('./constants'); const makeRequest = require('./make_request'); const {methodCreateOrder} = require('./lsps1_protocol'); const {methodGetInfo} = require('./lsps1_protocol'); const {methodGetOrder} = require('./lsps1_protocol'); const {probeDestination} = require('./../network'); const {versionJsonRpc} = require('./lsps1_protocol'); const daysAsBlocks = days => days * 144; const displayTokens = tokens => formatTokens({tokens}).display; const hoursAsBlocks = hours => hours * 6; const isAnnounced = type => type === 'public'; const isNumber = n => !!n && !isNaN(n); const isOutpoint = n => !!n && /^[0-9A-F]{64}:[0-9]{1,6}$/i.test(n); const isPublicKey = n => !!n && /^0[2-3][0-9A-F]{64}$/i.test(n); const isService = features => !!features.find(feature => feature.bit === 729); const knownTypes = ['private', 'public']; const maxAllowedWaitHours = 40; const niceAlias = n => `${(n.alias || n.id).trim()} ${n.id.substring(0, 8)}`; const split = n => n.split(':'); /** LSPS1 Client: Purchase an inbound channel open attempt LSPS1: https://github.com/BitcoinAndLightningLayerSpecs/lsp/tree/main/LSPS1 { ask: <Ask Function> capacity: <Inbound Channel Capacity Tokens Number> fs: { getFile: <Read File Contents Function> (path, cbk) => {} } [is_dry_run]: <Get Channel Price Quote Only Bool> [lifetime]: <Expected Minimum Channel Lifetime Existence Days Number> lnd: <Authenticated LND API Object> logger: <Winston Logger Object> max_wait_hours: <Requested Maximum Channel Open Wait Hours Count Number> [recovery]: <Existing Order Recovery String> [service_node]: <Provider Service Node Identity Public Key Hex String> type: <Inbound Channel Type String> } @returns via cbk or Promise */ module.exports = (args, cbk) => { return new Promise((resolve, reject) => { return asyncAuto({ // Check arguments validate: cbk => { if (!args.ask) { return cbk([400, 'ExpectedInquirerFunctionForLsp1Client']); } if (!isNumber(args.capacity)) { return cbk([400, 'ExpectedChannelCapacityAmountForLsp1Client']); } if (!args.fs) { return cbk([400, 'ExpectedFileSystemObjectForLsp1Client']); } if (!!args.is_dry_run && !!args.recovery) { return cbk([400, 'DryRunNotSupportedWithRecoveryMode']); } if (!!args.lifetime && !isNumber(args.lifetime)) { return cbk([400, 'ExpectedLifetimeDaysForLsps1Client']); } if (!args.lnd) { return cbk([400, 'ExpectedAuthenticatedLndForLsp1Client']); } if (!args.logger) { return cbk([400, 'ExpectedWinstonLoggerForLsp1Client']); } if (!isNumber(args.max_wait_hours)) { return cbk([400, 'ExpectedMaxOpenWaitHoursForLsp1Client']); } if (args.max_wait_hours > maxAllowedWaitHours) { return cbk([400, 'ExpectedLowerMaxWaitHoursForLsps1Client']); } // Exit early when this is a recovery scenario if (!!args.recovery) { return cbk(); } if (!!args.service_node && !isPublicKey(args.service_node)) { return cbk([400, 'ExpectedValidServiceNodeHexPubkeyForLsp1Client']); } if (!args.type) { return cbk([400, 'ExpectedOpenChannelTypeForLsp1Client']); } if (!knownTypes.includes(args.type)) { return cbk([400, 'ExpectedKnownChannelTypeForLsp1Client']); } return cbk(); }, // Get the network graph to see who is a potential LSPS1 server getGraph: ['validate', ({}, cbk) => { // Exit early when there is already a service specified to use if (!!args.service_node) { return cbk(); } return getNetworkGraph({lnd: args.lnd}, cbk); }], // Get the self node identity getId: ['validate', ({}, cbk) => getIdentity({lnd: args.lnd}, cbk)], // Determine what service node will be used service: ['getGraph', 'getId', ({getGraph, getId}, cbk) => { // Exit early when there was a service pre-selected if (!!args.service_node) { return cbk(null, args.service_node); } const {sortBy} = require('./../arrays'); const {sorted} = sortBy({ array: getGraph.nodes.filter(n => isService(n.features)), attribute: 'updated_at', }); const services = sorted.filter(n => n.public_key !== getId.public_key); if (!services.length) { return cbk([404, 'NoServicesOfferingChannelsFound']); } args.logger.info({ services: services.map(n => `${n.alias} ${n.public_key}`.trim()), }); return args.ask({ choices: services.map(node => ({ name: `${node.alias} ${node.public_key}`, value: node.public_key, })), loop: false, name: 'service', type: 'list', }, ({service}) => cbk(null, service)); }], // Get the alias of the service node getAlias: ['service', ({service}, cbk) => { return getNodeAlias({id: service, lnd: args.lnd}, cbk); }], // Connect to the service node connect: ['getAlias', ({getAlias}, cbk) => { const node = `${getAlias.id} ${getAlias.alias}`; // It may take a while to establish a connection with the peer args.logger.info({connecting_to: node}); return connectPeer({id: getAlias.id, lnd: args.lnd}, cbk); }], // Get the limits of the channel open service getLimits: ['connect', 'getAlias', ({getAlias}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } args.logger.info({ requesting_inbound_channel_capacity: displayTokens(args.capacity), }); return makeRequest({ lnd: args.lnd, method: methodGetInfo, service: getAlias.id, }, cbk); }], // Validate and format the limits limits: ['getLimits', ({getLimits}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } if (!getLimits.response || !getLimits.response.options) { return cbk([503, 'ExpectedLimitsInLsps1ServiceResponse']); } const {options} = getLimits.response; if (!!Number(options.min_initial_client_balance_sat)) { return cbk([501, 'Lsps1PushBalanceNotSupported']); } const maxCapacity = Number(options.max_channel_balance_sat); const minimumCapacity = Number(options.min_channel_balance_sat); // Make sure the requested capacity is at least the minimum if (args.capacity < minimumCapacity) { return cbk([400, 'RequestedCapacityTooLow', {min: minimumCapacity}]); } // Make sure the requested capacity isn't more than the maximum if (args.capacity > maxCapacity) { return cbk([400, 'RequestedCapacityTooHigh', {max: maxCapacity}]); } args.logger.info({ service_limits: { minimum_capacity: displayTokens(minimumCapacity), maximum_capacity: displayTokens(maxCapacity), }, website: getLimits.response.website || undefined, }); return cbk(); }], // Get a price quote getQuote: ['getAlias', 'limits', ({getAlias}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } args.logger.info({estimating_routing_fee_to: niceAlias(getAlias)}); const lifetime = daysAsBlocks(args.lifetime || defaultLifetimeDays); return makeRequest({ lnd: args.lnd, method: methodCreateOrder, params: { announce_channel: isAnnounced(args.type), channel_expiry_blocks: lifetime, client_balance_sat: Number().toString(), funding_confirms_within_blocks: hoursAsBlocks(args.max_wait_hours), lsp_balance_sat: args.capacity.toString(), required_channel_confirmations: defaultChannelActiveConfs, token: String(), }, service: getAlias.id, }, cbk); }], // Check the price quote quote: ['getQuote', ({getQuote}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(null, {id: args.recovery}); } if (!getQuote.response || !getQuote.response.payment) { return cbk([503, 'UnexpectedMissingQuoteInLsps1OpenQuoteResponse']); } if (!getQuote.response.payment.bolt11) { return cbk([503, 'UnexpectedMissingBolt11InLsps1OpenQuoteResponse']); } if (!getQuote.response.order_id) { return cbk([503, 'UnexpectedAbsentOrderIdInLsps1OpenQuoteResponse']); } const request = getQuote.response.payment.bolt11.invoice; if (!request) { return cbk([503, 'UnexpectedMissingPaymentRequestInQuoteResponse']); } try { parsePaymentRequest({request}); } catch (err) { return cbk([503, 'UnexpectedInvalidPayReqInQuoteResponse', {err}]); } return cbk(null, { request, id: getQuote.response.order_id, }); }], // Probe to determine the routing fee getFee: ['quote', ({quote}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } return probeDestination({ fs: args.fs, lnd: args.lnd, logger: args.logger, request: quote.request, }, cbk); }], // Confirm payment accept: ['getFee', 'quote', ({getFee, quote}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } const capacity = displayTokens(args.capacity); const displayTotal = displayTokens(getFee.probed + getFee.fee); args.logger.info({ order: { recovery_id: quote.id, payment_request: quote.request, opening_fee: displayTokens(getFee.probed), routing_fee: displayTokens(getFee.fee).trim() || undefined, overall_fee: !!getFee.fee ? displayTotal : undefined, }, }); // Exit early when this is a dry run and nothing will be paid if (args.is_dry_run) { return cbk(); } return args.ask({ default: true, message: `Pay ${displayTotal} to get ${capacity} inbound channel?`, name: 'ok', type: 'confirm', }, ({ok}) => { if (!ok) { return cbk([400, 'PurchaseChannelPriceNotAccepted']); } return cbk(); }); }], // Make the payment pay: ['accept', 'getFee', 'quote', ({getFee, quote}, cbk) => { // Exit early when recovering if (!!args.recovery) { return cbk(); } // Exit early and do not pay when this is a dry run if (args.is_dry_run) { args.logger.info({is_dry_run: true}); return cbk(); } return probeDestination({ fs: args.fs, is_real_payment: true, lnd: args.lnd, logger: args.logger, max_fee: getFee.fee, request: quote.request, }, cbk); }], // Ask for order status getOrder: ['pay', 'quote', 'service', ({pay, quote, service}, cbk) => { // Exit early when there was no real order if (!!args.is_dry_run) { return cbk(); } if (!args.recovery && !!pay) { args.logger.info({ paid: displayTokens(pay.paid), payment_id: pay.id, payment_proof_preimage: pay.preimage, }); } args.logger.info({requesting_order_status: quote.id}); return makeRequest({ service, lnd: args.lnd, method: methodGetOrder, params: {order_id: quote.id}, }, cbk); }], order: ['getOrder', ({getOrder}, cbk) => { // Exit early when there was no real order if (!!args.is_dry_run) { return cbk(); } if (!getOrder.response || !getOrder.response.payment) { return cbk([503, 'UnexpectedMissingResponseForGetOrderInfo']); } if (!getOrder.response.payment.state) { return cbk([503, 'UnexpectedPaymentStateInGetOrderInfoResponse']); } const {state} = getOrder.response.payment; // Exit early when there is no channel if (!getOrder.response.channel) { args.logger.info({order_status: state}); return cbk(); } if (!isOutpoint(getOrder.response.channel.funding_outpoint)) { return cbk([503, 'UnexpectedChannelFundingOutpointInResponse']); } const [id, vout] = split(getOrder.response.channel.funding_outpoint); args.logger.info({ transaction_id: id, transaction_output_index: vout, order_status: getOrder.response.payment.state, }); return cbk(); }], }, returnResult({reject, resolve}, cbk)); }); };