UNPKG

paid-services

Version:
522 lines (449 loc) 16.7 kB
const {randomBytes} = require('crypto'); const asyncAuto = require('async/auto'); const asyncReflect = require('async/reflect'); const asyncUntil = require('async/until'); const {acceptsChannelOpen} = require('ln-sync'); const {broadcastChainTransaction} = require('ln-service'); const {broadcastTransaction} = require('ln-sync'); const {closeChannel} = require('ln-service'); const {getChainFeeRate} = require('ln-service'); const {getChainTransactions} = require('ln-service'); const {getChannels} = require('ln-service'); const {getFeeRates} = require('ln-service'); const {getNetwork} = require('ln-sync'); const {payments} = require('bitcoinjs-lib'); const {reserveTransitFunds} = require('ln-sync'); const {returnResult} = require('asyncjs-util'); const {stopAllHtlcs} = require('ln-sync'); const {Transaction} = require('bitcoinjs-lib'); const askForChangeDetails = require('./ask_for_change_details'); const {makePeerRequest} = require('./../p2p'); const parseAcceptRequest = require('./parse_accept_request'); const proposeCapacityChange = require('./propose_capacity_change'); const {servicePeerRequests} = require('./../p2p'); const {serviceTypeAcceptCapacityChange} = require('./../service_types'); const {serviceTypeChangeCapacity} = require('./../service_types'); const {serviceTypeSignCapacityChange} = require('./../service_types'); const {serviceTypeWaitOnConfirmation} = require('./../service_types'); const bufferAsHex = buffer => buffer.toString('hex'); const capacityChangeRequestIdType = '0'; const customMessageType = 50800; const family = 0; const familyTemporary = 805; const {fromHex} = Transaction; const hexAsBuffer = hex => Buffer.from(hex, 'hex'); const idRecordType = '0'; const {isArray} = Array; const makeId = () => randomBytes(32).toString('hex'); const messageFrequencyMs = 3000; const minimumAddTokens = 20000; const multiKeyLimit = 100000; const openTxType = '1'; const {p2wpkh} = payments; const range = max => [...Array(max).keys()]; const rebroadcastTimeMs = 1000 * 5; const recordsAsObject = a => a.reduce((sum, n) => sum[n.type] = n.value, {}); const requestCapacityChangeIntervalMs = 2000; const requestCapacityChangeTimeoutMs = 1000 * 60 * 60 * 6; const testMessage = '00'; const tokensAsBigUnit = tokens => (tokens / 1e8).toFixed(8); const uniq = arr => Array.from(new Set(arr)); const waitingPingDelayMs = 1000 * 5; const waitingTimeoutMs = 1000 * 30; /** Initiate a channel capacity change { ask: <Ask Function> lnd: <Authenticated LND API Object> logger: <Winston Logger Object> nodes: [{ lnd: <Potential Move Node LND API Object> node_name: <Potential Move Node Name String> public_key: <Potential Move Node Public Key Hex String> }] } @returns via cbk or Promise { base_fee_mtokens: <Forwarding Base Fee Millitokens String> cltv_delta: <Forwarding CLTV Delta Number> fee_rate: <Forwarding Parts Per Million Fee Rate Number> open_lnd: <Channel Open LND API Object> transaction_id: <Replacement Channel Transaction Id Hex String> transaction_vout: <Replacement Channel Transaction Output Index Number> } */ module.exports = ({ask, lnd, logger, nodes}, cbk) => { return new Promise((resolve, reject) => { return asyncAuto({ // Generate a unique identifier for the change id: cbk => cbk(null, makeId()), // Check arguments validate: cbk => { if (!ask) { return cbk([400, 'ExpectedAskFunctionToInitiateCapacityChange']); } if (!lnd) { return cbk([400, 'ExpectedLndToInitiateCapacityChange']); } if (!logger) { return cbk([400, 'ExpectedLoggerObjectToInitiateCapacityChange']); } if (!isArray(nodes)) { return cbk([400, 'ExpectedArrayOfCandidateMoveNodesToInitiate']); } return cbk(); }, // Get the network name getNetwork: ['validate', ({}, cbk) => getNetwork({lnd}, cbk)], // Ask for all the capacity change details askForChangeDetails: ['id', 'validate', ({id}, cbk) => { return askForChangeDetails({ask, id, lnd, nodes}, cbk); }], // Propose the theoretical new channel to the peer to confirm a change checkAccept: ['askForChangeDetails', ({askForChangeDetails}, cbk) => { // Check that a channel open would be accepted return acceptsChannelOpen({ capacity: askForChangeDetails.estimated_capacity, cooperative_close_address: askForChangeDetails.coop_close_address, give_tokens: askForChangeDetails.remote_balance, is_private: askForChangeDetails.is_private, lnd: askForChangeDetails.open_lnd, min_htlc_mtokens: askForChangeDetails.min_htlc_mtokens, partner_csv_delay: askForChangeDetails.partner_csv_delay, partner_public_key: askForChangeDetails.partner_public_key, }, cbk); }], // Get the additional capacity funding getFunding: [ 'askForChangeDetails', 'checkAccept', ({askForChangeDetails}, cbk) => { // Exit early when additional funding is not necessary if (!askForChangeDetails.increase) { return cbk(null, {}); } const tokens = askForChangeDetails.increase; // Get a signed tx ready that pays the increase in funds return reserveTransitFunds({ask, lnd, logger, tokens}, cbk); }], // Wait for the basic capacity change proposal to be acknowledged sendBasicRequest: [ 'askForChangeDetails', 'getFunding', 'id', ({askForChangeDetails, getFunding, id}, cbk) => { const records = []; const response = {}; const service = servicePeerRequests({lnd}); const start = new Date(); const type = serviceTypeAcceptCapacityChange; if (!!getFunding.refund) { logger.info({add_funds_signed_refund_tx: getFunding.refund}); } if (!!askForChangeDetails.open_transaction) { records.push({ type: openTxType, value: askForChangeDetails.open_transaction, }); } // Listen for the acceptance of the capacity change service.request({type}, (req, res) => { // Exit early when the request is not from the peer if (req.from !== askForChangeDetails.partner_public_key) { return; } const findIdRecord = records => records.find(n => n.type === '0'); const idRecord = findIdRecord(req.records); // Exit early when the request is not for this change request if (!idRecord || idRecord.value !== id) { return; } logger.info({peer_accepted_request: true}); // Stop waiting for a response now that the request has been ack'ed service.stop({}); res.success({records}); const {transaction} = parseAcceptRequest({ records: req.records, open_transaction: askForChangeDetails.open_transaction, transaction_id: askForChangeDetails.transaction_id, }); if (!transaction) { const error = [400, 'FundingTxRequiredToChangeCapacity']; response.error = error; return res.failure(error); } response.transaction = transaction; return; }); logger.info({waiting_for_peer_acceptance: true}); // This is a repeating request, the peer might not be online return asyncUntil( cbk => { if (!!response.error || !!response.transaction) { return cbk(null, true); } if (new Date() - start > requestCapacityChangeTimeoutMs) { return cbk([408, 'TimedOutWaitingForChangeAcceptance']); } // Stop new pings when there is an ack of the change request return cbk(null, false); }, cbk => { return makePeerRequest({ lnd, records: askForChangeDetails.records, timeout: requestCapacityChangeIntervalMs, to: askForChangeDetails.partner_public_key, type: serviceTypeChangeCapacity, }, () => { // Continue pinging with the proposal return setTimeout(cbk, requestCapacityChangeIntervalMs); }); }, err => { service.stop({}); if (!!response.error) { return cbk(response.error); } if (!!err) { return cbk(err); } return cbk(null, response.transaction); } ); }], // Get the current channels to double check there is enough local balance getPeerChannels: [ 'askForChangeDetails', 'sendBasicRequest', ({askForChangeDetails}, cbk) => { return getChannels({ lnd, partner_public_key: askForChangeDetails.partner_public_key, }, cbk); }], // Stop all HTLCs with the peer until the channel is closed stopHtlcs: [ 'askForChangeDetails', 'getPeerChannels', ({askForChangeDetails, getPeerChannels}, cbk) => { return stopAllHtlcs({ lnd, id: askForChangeDetails.id, ids: getPeerChannels.channels.map(n => n.id), peer: askForChangeDetails.partner_public_key, }, cbk); }], // Broadcast and confirm the add funds transaction into a block confirmAddFunds: [ 'askForChangeDetails', 'getFunding', 'getPeerChannels', 'id', 'sendBasicRequest', asyncReflect(({ askForChangeDetails, getFunding, getPeerChannels, id, sendBasicRequest, }, cbk) => { const channel = getPeerChannels.channels.find(({id}) => { return id === askForChangeDetails.id; }); if (!channel) { return cbk([400, 'FailedToFindCapacityChangeChannel']); } const localBalance = channel.local_balance; if (!!channel.pending_payments.length) { return cbk([400, 'ExpectedNoPendingPaymentsToChangeCapacity']); } // Make sure that we still have enough balance in local balance if (localBalance + askForChangeDetails.estimated_local_delta < 0) { return cbk([400, 'InsufficientRemainingFundsToChangeCapacity']); } let isHeartbeatAcked = false; let isReady = false; // This will take some time so start pinging the peer that we are here asyncUntil( cbk => cbk(null, isReady), cbk => { return makePeerRequest({ lnd, records: [{type: capacityChangeRequestIdType, value: id}], timeout: waitingTimeoutMs, to: askForChangeDetails.partner_public_key, type: serviceTypeWaitOnConfirmation, }, err => { // The peer needs to respond to waiting heartbeats if (!!err) { return cbk(err); } isHeartbeatAcked = true; return setTimeout(cbk, waitingPingDelayMs); }); }, cbk ); // Exit early when there is no funding transaction to broadcast if (!getFunding.transaction) { isReady = true; return; } const channelId = askForChangeDetails.id; // Get the funding confirmed return asyncUntil( cbk => { return getChainTransactions({lnd}, (err, res) => { if (!!err) { return cbk(err); } const tx = res.transactions .filter(tx => tx.is_confirmed) .find(tx => tx.transaction === getFunding.transaction); // Exit early when there is no confirmed transaction if (!tx) { logger.info({waiting_for_funds_confirmation: getFunding.id}); return cbk(null, false); } // Transaction is confirmed in a block logger.info({funds_confirmed: true}); return cbk(null, true); }); }, cbk => { return broadcastChainTransaction({ lnd, description: `Funds for increasing capacity on ${channelId}`, transaction: getFunding.transaction, }, err => { if (!!err) { logger.error({err}); } return setTimeout(cbk, rebroadcastTimeMs); }); }, () => { // Stop the "we are here" pinging return isReady = true; } ); })], // Propose the capacity change to the peer proposeChange: [ 'askForChangeDetails', 'confirmAddFunds', 'getFunding', 'getNetwork', 'getPeerChannels', 'id', 'sendBasicRequest', asyncReflect(({ askForChangeDetails, getFunding, getMigrationLnd, getNetwork, getPeerChannels, id, sendBasicRequest, }, cbk) => { const channel = getPeerChannels.channels.find(channel => { return channel.id === askForChangeDetails.id }); logger.info({ change_channel: { id: askForChangeDetails.id, transaction_id: channel.transaction_id, transaction_vout: channel.transaction_vout, }, }); return proposeCapacityChange({ id, lnd, logger, bitcoinjs_network: getNetwork.bitcoinjs, channel: askForChangeDetails.id, decrease: askForChangeDetails.decrease, increase: askForChangeDetails.increase, increase_key: getFunding.key, increase_key_index: getFunding.index, increase_output_script: getFunding.output, increase_transaction: getFunding.transaction, increase_transaction_id: getFunding.id, increase_transaction_vout: getFunding.vout, increase_witness_script: getFunding.script, is_private: askForChangeDetails.is_private, open_lnd: askForChangeDetails.open_lnd, open_transaction: sendBasicRequest, partner_public_key: askForChangeDetails.partner_public_key, transaction_id: channel.transaction_id, transaction_vout: channel.transaction_vout, }, cbk); })], // The replacement channel details replacementChannel: [ 'askForChangeDetails', 'proposeChange', ({askForChangeDetails, proposeChange}, cbk) => { // Exit early with no replacement when the proposal fails if (!!proposeChange.error) { return cbk(); } logger.info({published_replacement_channel: proposeChange.value}); return cbk(null, { base_fee_mtokens: askForChangeDetails.base_fee_mtokens, cltv_delta: askForChangeDetails.cltv_delta, fee_rate: askForChangeDetails.fee_rate, open_from: askForChangeDetails.open_from || undefined, open_lnd: askForChangeDetails.open_lnd, transaction_id: proposeChange.value.transaction_id, transaction_vout: proposeChange.value.transaction_vout, }); }], // Broadcast the add capacity refund transaction if there was an error executeRefund: [ 'confirmAddFunds', 'getFunding', 'proposeChange', ({confirmAddFunds, getFunding, proposeChange}, cbk) => { // Exit early when there were no funds added if (!confirmAddFunds) { return cbk(); } // Exit early when there is no need to refund if (!confirmAddFunds.error && !proposeChange.error) { return cbk(); } const capacityChangeErr = confirmAddFunds.error || proposeChange.error; return broadcastChainTransaction({ lnd, transaction: getFunding.refund, description: `Refund for failed capacity change`, }, err => { if (!!err) { logger.error({err}); } return cbk(capacityChangeErr); }); }], }, returnResult({reject, resolve, of: 'replacementChannel'}, cbk)); }); };