balanceofsatoshis
Version:
Lightning balance CLI
1,628 lines (1,401 loc) • 52.4 kB
JavaScript
const {createHash} = require('crypto');
const {addressForScript} = require('goldengate');
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const asyncTimesSeries = require('async/timesSeries');
const {attemptSweep} = require('goldengate');
const {cancelSwapOut} = require('goldengate');
const {checkSwapTiming} = require('goldengate');
const {createChainAddress} = require('ln-service');
const {createInvoice} = require('ln-service');
const {createSwapOut} = require('goldengate');
const {decodeSwapRecovery} = require('goldengate');
const {encodeSwapRecovery} = require('goldengate');
const {decodePaymentRequest} = require('ln-service');
const {findDeposit} = require('goldengate');
const {findKey} = require('ln-sync');
const {formatTokens} = require('ln-sync');
const {getChainFeeRate} = require('ln-service');
const {getChannels} = require('ln-service');
const {getHeight} = require('ln-service');
const {getIdentity} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getNode} = require('ln-service');
const {getNodeAlias} = require('ln-sync');
const {getPayment} = require('ln-service');
const {getSwapOutQuote} = require('goldengate');
const {getSwapOutTerms} = require('goldengate');
const {getSyntheticOutIgnores} = require('probing');
const {lightningLabsSwapService} = require('goldengate');
const moment = require('moment');
const {payViaRoutes} = require('ln-service');
const {releaseSwapOutSecret} = require('goldengate');
const {returnResult} = require('asyncjs-util');
const {subscribeToBlocks} = require('ln-service');
const {subscribeToMultiPathPay} = require('probing');
const {subscribeToMultiPathProbe} = require('probing');
const {subscribeToPastPayment} = require('ln-service');
const {subscribeToPayViaRequest} = require('ln-service');
const {subscribeToSwapOutStatus} = require('goldengate');
const {Transaction} = require('bitcoinjs-lib');
const {authenticatedLnd} = require('./../lnd');
const {chains} = require('./../network/networks');
const channelForSend = require('./channel_for_send');
const {cltvDeltaBuffer} = require('./constants');
const {currencySymbols} = require('./../network/networks');
const {describeRoute} = require('./../display');
const {describeRoutingFailure} = require('./../display');
const {estimatedSweepVbytes} = require('./constants');
const {executeProbe} = require('./../network');
const {fastDelayMinutes} = require('./constants');
const {feeRateDenominator} = require('./constants');
const {fuzzBlocks} = require('./constants');
const {getIcons} = require('./../display');
const {getIgnores} = require('./../routing');
const getRoutesForFunding = require('./get_routes_for_funding');
const getPaidService = require('./get_paid_service');
const getRawRecoveries = require('./get_raw_recoveries');
const {getTags} = require('./../tags');
const {maxCltvExpiration} = require('./constants');
const {maxDepositTokens} = require('./constants');
const {maxExecutionFeeTokens} = require('./constants');
const {maxFeeMultiplier} = require('./constants');
const {maxFeeRate} = require('./constants');
const {maxPathfindingMs} = require('./constants');
const {maxRouteFailProbability} = require('./constants');
const {maxRoutingFeeDenominator} = require('./constants');
const {minCltvDelta} = require('./constants');
const {minConfs} = require('./constants');
const {minSweepConfs} = require('./constants');
const {minutesPerBlock} = require('./constants');
const {requiredBufferBlocks} = require('./constants');
const {slowDelayMinutes} = require('./constants');
const {swappable} = require('./../network/networks');
const {sweepProgressLogDelayMs} = require('./constants');
const addressMatch = /\b((bc|tb)(0([ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59})|1[ac-hj-np-z02-9]{8,87})|([13]|[mn2])[a-km-zA-HJ-NP-Z1-9]{25,39})\b/i;
const {ceil} = Math;
const cltvBuffer = 3;
const farFutureDate = () => moment().add(1, 'years').toISOString();
const flatten = arr => [].concat(...arr);
const {floor} = Math;
const hexAsBase64 = hex => Buffer.from(hex, 'hex').toString('base64');
const {isArray} = Array;
const {max} = Math;
const maxCltvDelta = 144 * 30;
const {min} = Math;
const minSend = spendTokens => Number(spendTokens) + 1e4;
const mtokPerTok = BigInt(1000);
const {round} = Math;
const sha256 = n => createHash('sha256').update(Buffer.from(n, 'hex'));
const swapDelayMinutes = fast => !!fast ? fastDelayMinutes : slowDelayMinutes;
const tokensAsBigUnit = tokens => ((tokens || 0) / 1e8).toFixed(8);
const uniq = arr => Array.from(new Set(arr));
/** Get additional inbound liquidity
{
[api_key]: <API Key CBOR String>
avoid: [<Avoid Forwarding Through Node With Public Key Hex String>]
confs: <Confirmations to Wait for Deposit Number>
fetch: <Fetch Function>
fs: {
getFile: <Read File Contents Function> (path, cbk) => {}
}
[is_fast]: <Execute Swap Immediately Bool>
[is_dry_run]: <Avoid Actually Executing Operation Bool>
[is_raw_recovery_shown]: <Show Raw Recovery Transactions Bool>
lnd: <Authenticated LND gRPC API Object>
logger: <Winston Logger Object>
[max_deposit]: <Maximum Swap Deposit Tokens Number>
[max_fee]: <Maximum Fee Tokens Number>
[max_paths]: <Maximum Paths For Funding Number>
[max_wait_blocks]: <Maximum Wait Blocks Number>
[node]: <Node Name String>
[out_address]: <Out Address String>
[peer]: <Peer Public Key Hex String>
[recovery]: <Recover In-Progress Swap Hex String>
request: <Request Function>
[socket]: <Custom Backing Service Host:Port String>
[spend_address]: <Attempt Spend Out To Address String>
[spend_tokens]: <Spend Address Exact Tokens Number>
timeout: <Wait for Deposit Timeout Milliseconds Number>
tokens: <Tokens Number>
}
@returns via cbk or Promise
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!isArray(args.avoid)) {
return cbk([400, 'ExpectedAvoidArrayToInitiateSwapOut']);
}
if (args.confs === undefined) {
return cbk([400, 'ExpectedConfirmationsCountToConsiderReorgSafe']);
}
if (!args.fetch) {
return cbk([400, 'ExpectedFetchFunctionToInitiateSwapOut']);
}
if (!args.fs) {
return cbk([400, 'ExpectedFileSystemFunctionsToInitiateSwapOut']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedLndToInitiateSwapOut']);
}
if (!args.logger) {
return cbk([400, 'ExpectedLoggerForSwapProgressNotifications']);
}
if (!!args.out_address && !addressMatch.test(args.out_address)) {
return cbk([400, 'UnrecognizedFormatOfOutAddress']);
}
if (!args.request) {
return cbk([400, 'ExpectedRequestFunctionToInitiateSwapOut']);
}
if (!!args.spend_address && !args.spend_tokens) {
return cbk([400, 'ExpectedSpendAmountWhenSpecifyingSpendAddress']);
}
if (!!args.spend_tokens && !args.spend_address) {
return cbk([400, 'ExpectedSpendAddressWhenSpecifyingSpendTokens']);
}
if (args.spend_tokens > args.tokens) {
return cbk([400, 'ExpectedSpendTokensLessThanTotalTokens']);
}
if (!!args.spend_address && minSend(args.spend_tokens) > args.tokens) {
return cbk([
400,
'ExpectedSwapAmountGreaterThanSpendAmount',
{minimum: minSend(args.spend_tokens)},
]);
}
if (!args.timeout) {
return cbk([400, 'ExpectedTimeoutToWaitForSwapDeposit']);
}
if (!args.recovery && !args.tokens) {
return cbk([400, 'ExpectedTokensToIncreaseLiquidity']);
}
return cbk();
},
// Find peer
findPeer: ['validate', ({}, cbk) => {
return findKey({lnd: args.lnd, query: args.peer}, cbk);
}],
// Get current channel liquidity details
getChannels: ['validate', ({}, cbk) => {
return getChannels({lnd: args.lnd, is_active: true}, cbk);
}],
// Get the current block height
getHeight: ['validate', ({}, cbk) => getHeight({lnd: args.lnd}, cbk)],
// Get node icons
getIcons: ['validate', ({}, cbk) => {
if (!args.fs) {
return cbk();
}
return getIcons({fs: args.fs}, cbk);
}],
// Get the node public key
getIdentity: ['validate', ({}, cbk) => {
// Exit early when there are no avoids
if (!args.avoid.length) {
return cbk();
}
return getIdentity({lnd: args.lnd}, cbk);
}],
// Get the network this swap is taking place on
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Get tags for figuring out avoid flags
getTags: ['validate', ({}, cbk) => {
// Exit early when there are no avoids
if (!args.avoid.length) {
return cbk();
}
return getTags({fs: args.fs}, cbk);
}],
// Get peer details
getPeerDetails: ['findPeer', ({findPeer}, cbk) => {
if (!findPeer.public_key) {
return cbk();
}
return getNodeAlias({id: findPeer.public_key, lnd: args.lnd}, cbk);
}],
// Decode the swap recovery if necessary
recover: ['validate', ({}, cbk) => {
if (!args.recovery) {
return cbk();
}
return decodeSwapRecovery({recovery: args.recovery}, cbk);
}],
// External sends
sends: ['validate', ({}, cbk) => {
if (!args.spend_address) {
return cbk();
}
return cbk(null, [{
address: args.spend_address,
tokens: Number(args.spend_tokens),
}]);
}],
// Create a sweep address
createAddress: ['recover', ({recover}, cbk) => {
// Exit early when there is a sweep address specified in recovery
if (!!recover && recover.sweep_address) {
return cbk(null, {address: recover.sweep_address});
}
// Exit early when the sweep out address is directly specified
if (!!args.out_address) {
return cbk(null, {address: args.out_address});
}
return createChainAddress({format: 'p2wpkh', lnd: args.lnd}, cbk);
}],
// Network for swap
network: ['getNetwork', ({getNetwork}, cbk) => {
return cbk(null, getNetwork.network);
}],
// The start height of the swap
startHeight: ['getHeight', 'recover', ({getHeight, recover}, cbk) => {
// Exit early when recovering from an in-progress swap
if (!!recover) {
return cbk(null, recover.start_height);
}
return cbk(null, getHeight.current_block_height);
}],
// Figure out which channel to use when swapping with a peer
channel: ['findPeer', 'getChannels', ({findPeer, getChannels}, cbk) => {
// Exit early when this is a recovery, or there is no peer selected
if (!!args.recovery || !findPeer.public_key) {
return cbk();
}
const {id} = channelForSend({
tokens: args.tokens,
channels: getChannels.channels,
peer: findPeer.public_key,
});
// There was no channel found to use for the swap
if (!id) {
return cbk([400, 'InsufficientOutboundLiquidityToConvertToInbound']);
}
return cbk(null, {id});
}],
// Get base ignores
getBaseIgnores: [
'getChannels',
'getIdentity',
'getTags',
({getChannels, getIdentity, getTags}, cbk) =>
{
// Exit early when there are no avoids
if (!args.avoid.length) {
return cbk(null, {ignore: []});
}
return getIgnores({
avoid: args.avoid,
channels: getChannels.channels,
lnd: args.lnd,
logger: args.logger,
public_key: getIdentity.public_key,
tags: getTags.tags,
},
cbk);
}],
// Get the ignore list from avoid directives
getIgnores: [
'findPeer',
'getBaseIgnores',
({findPeer, getBaseIgnores}, cbk) =>
{
// Exit early when there is no specific peer
if (!findPeer.public_key) {
return cbk(null, {ignore: getBaseIgnores.ignore});
}
return getSyntheticOutIgnores({
ignore: getBaseIgnores.ignore,
lnd: args.lnd,
out: [findPeer.public_key],
},
cbk);
}],
// Currency of swap
currency: ['network', ({network}, cbk) => {
return cbk(null, currencySymbols[network]);
}],
// Derive recovery address for an in-progress swap
recoverAddress: ['network', 'recover', ({network, recover}, cbk) => {
if (!args.recovery) {
return cbk();
}
try {
addressForScript({network, script: recover.script})
} catch (err) {
return cbk([400, 'FailedToDeriveSwapAddress', {err}]);
}
const {address} = addressForScript({network, script: recover.script});
return cbk(null, address);
}],
// Get a paid service object, or convert prepaid token to service object
getService: [
'network',
'recover',
'recoverAddress',
({network}, cbk) =>
{
// Exit early when the swap is already initiated
if (!!args.recovery) {
return cbk();
}
return getPaidService({
network,
fetch: args.fetch,
lnd: args.lnd,
socket: args.socket,
token: args.api_key,
},
cbk);
}],
// Get swap out limits
getLimits: ['getService', ({getService}, cbk) => {
// Exit early when in recovery
if (!!args.recovery) {
return cbk();
}
return getSwapOutTerms({
metadata: getService.metadata,
service: getService.service,
},
cbk);
}],
// Get the quote for swaps
getQuote: [
'getLimits',
'getService',
'startHeight',
({getLimits, getService, startHeight}, cbk) =>
{
// Exit early when this is a recovery of an existing swap
if (!!args.recovery) {
return cbk();
}
if (args.tokens > getLimits.max_tokens) {
return cbk([400, 'SwapSizeTooLarge', {max: getLimits.max_tokens}]);
}
if (args.tokens < getLimits.min_tokens) {
return cbk([400, 'SwapSizeTooSmall', {min: getLimits.min_tokens}]);
}
const fundAt = moment().add(swapDelayMinutes(args.is_fast), 'minutes');
return getSwapOutQuote({
delay: !args.is_fast ? fundAt.toISOString() : undefined,
metadata: getService.metadata,
service: getService.service,
timeout: getLimits.max_cltv_delta + startHeight,
tokens: args.tokens,
},
cbk);
}],
// Check quote to validate parameters of the swap
checkQuote: ['getLimits', 'getQuote', ({getLimits, getQuote}, cbk) => {
if (!!args.recovery) {
return cbk();
}
if (getQuote.deposit > (args.max_deposit || maxDepositTokens)) {
return cbk([400, 'SwapDepositExceedsMaxDepositLimit']);
}
if (getQuote.fee > round(args.tokens * maxFeeRate)) {
return cbk([400, 'TotalFeeExceedsMaxFeeRate']);
}
if (!!args.max_fee && getQuote.fee > args.max_fee) {
return cbk([400, 'FeeForSwapExceedsMaximumFeeLimit', getQuote]);
}
const fundConfs = (args.confs || minConfs);
const swapDelayMin = swapDelayMinutes(args.is_fast);
const sweepConfs = (args.confs || minConfs);
const allFees = getQuote.fee;
const swapMinimumMinutes = (fundConfs + sweepConfs) * minutesPerBlock;
const swapTimeoutMinutes = getLimits.max_cltv_delta * minutesPerBlock;
const fastestSwapTime = moment().add(swapMinimumMinutes, 'minutes');
const swapTimeout = moment().add(swapTimeoutMinutes, 'minutes');
args.logger.info({
estimated_time: {
start_at: moment().calendar(),
earliest_completion: fastestSwapTime.add(swapDelayMin).fromNow(),
forfeit_funds_deadline_at: swapTimeout.fromNow(),
},
});
return cbk(null, {deposit: getQuote.deposit, service_fee: allFees});
}],
// Get the ultimate timeout height to request for the swap
getTimeout: [
'getLimits',
'startHeight',
({getLimits, startHeight}, cbk) =>
{
// Exit early when the swap is already started
if (!!args.recovery) {
return cbk();
}
if (getLimits.max_cltv_delta < minCltvDelta) {
return cbk([503, 'ServerMaxCltvDeltaTooLow']);
}
return cbk(null, startHeight + getLimits.max_cltv_delta);
}],
// Request a new swap out
initiateSwap: [
'checkQuote',
'getService',
'getTimeout',
'network',
'recover',
'recoverAddress',
({getService, getTimeout, network, recover, recoverAddress}, cbk) =>
{
// Exit early when the swap is already initiated
if (!!args.recovery) {
return cbk(null, {
address: recoverAddress,
private_key: recover.claim_private_key,
script: recover.script,
secret: recover.secret,
start_height: recover.start_height,
timeout: recover.timeout,
version: recover.version,
});
}
if (!!getService.paid && !!getService.token) {
args.logger.info({
amount_paid_for_api_key: tokensAsBigUnit(getService.paid),
service_api_key: getService.token,
service_user_id: getService.id,
});
}
const fundAt = moment().add(swapDelayMinutes(args.is_fast), 'minutes');
return createSwapOut({
network,
fund_at: fundAt.toISOString(),
metadata: getService.metadata,
service: getService.service,
timeout: getTimeout,
tokens: args.tokens,
},
cbk);
}],
// Decode swap execution request
decodeExecutionRequest: ['initiateSwap', ({initiateSwap}, cbk) => {
if (!!args.recovery) {
return cbk();
}
return decodePaymentRequest({
lnd: args.lnd,
request: initiateSwap.swap_execute_request,
},
cbk);
}],
// Check swap
checkSwap: [
'createAddress',
'decodeExecutionRequest',
'initiateSwap',
'startHeight',
({
createAddress,
decodeExecutionRequest,
initiateSwap,
startHeight,
},
cbk) =>
{
// Exit early when the swap is already started or is just a test run
if (!!args.is_dry_run || !!args.recovery) {
return cbk();
}
// Output a recovery blob that can be used to restart the swap
try {
const {recovery} = encodeSwapRecovery({
claim_private_key: initiateSwap.private_key,
execution_id: decodeExecutionRequest.id,
refund_public_key: initiateSwap.service_public_key,
secret: initiateSwap.secret,
start_height: startHeight,
sweep_address: createAddress.address,
timeout: initiateSwap.timeout,
tokens: args.tokens,
version: initiateSwap.version,
});
args.logger.info({
restart_recovery_secret: recovery.toString('hex'),
});
} catch (err) {
return cbk([500, 'UnexpectedErrorGeneratingRecoveryState', {err}]);
}
try {
checkSwapTiming({
current_block_height: startHeight,
required_buffer_blocks: requiredBufferBlocks,
required_funding_confirmations: args.confs,
required_sweep_confirmations: args.confs,
timeout_height: initiateSwap.timeout,
});
} catch (err) {
return cbk([503, 'InsufficientTimeAvailableToCompleteSwap', {err}]);
}
return cbk();
}],
// Decode funding request
decodeFundingRequest: ['initiateSwap', ({initiateSwap}, cbk) => {
if (!!args.recovery) {
return cbk();
}
return decodePaymentRequest({
lnd: args.lnd,
request: initiateSwap.swap_fund_request,
},
cbk);
}],
// Track server swap status
trackStatus: [
'decodeFundingRequest',
'getService',
({decodeFundingRequest, getService}, cbk) =>
{
if (!!args.recovery) {
return cbk();
}
const sub = subscribeToSwapOutStatus({
id: decodeFundingRequest.id,
metadata: getService.metadata,
service: getService.service,
});
const swap = {};
sub.on('status_update', update => {
// Server reports swap broadcast
if (!swap.is_broadcast && !!update.is_broadcast) {
swap.is_broadcast = true;
args.logger.info({
server_update: 'On-chain transaction published',
});
}
if (!!update.is_claimed) {
sub.removeAllListeners();
return cbk();
}
});
}],
// Check that the payment requests match the validated quote
checkRequestAmounts: [
'decodeExecutionRequest',
'decodeFundingRequest',
'getQuote',
({decodeExecutionRequest, decodeFundingRequest, getQuote}, cbk) =>
{
if (!!args.recovery) {
return cbk();
}
// Check that the no-strings-attached prepay is as quoted
if (decodeExecutionRequest.tokens !== getQuote.deposit) {
return cbk([503, 'UnexpectedUnilateralDepositTokensAmount']);
}
if (decodeFundingRequest.tokens > getQuote.fee + args.tokens) {
return cbk([503, 'UnexpectedServiceCostForSwap']);
}
return cbk();
}],
// Probe for execution
findRouteForExecution: [
'channel',
'decodeExecutionRequest',
'decodeFundingRequest',
'getIcons',
'getIgnores',
'getService',
({
channel,
decodeExecutionRequest,
decodeFundingRequest,
getIcons,
getIgnores,
getService,
},
cbk) =>
{
// Exit early when there is a swap recovery
if (!!args.recovery) {
return cbk();
}
const isFeatured = !!decodeExecutionRequest.features.length;
return executeProbe({
cltv_delta: decodeExecutionRequest.cltv_delta + cltvBuffer,
destination: decodeExecutionRequest.destination,
features: !!isFeatured ? decodeExecutionRequest.features : undefined,
ignore: getIgnores.ignore,
lnd: args.lnd,
logger: args.logger,
max_fee: maxExecutionFeeTokens,
mtokens: decodeExecutionRequest.mtokens,
outgoing_channel: !!channel ? channel.id : undefined,
payment: decodeExecutionRequest.payment,
routes: decodeExecutionRequest.routes,
tagged: !!getIcons ? getIcons.nodes : undefined,
tokens: decodeExecutionRequest.tokens,
},
(err, res) => {
if (!!err || !res.route) {
return cancelSwapOut({
id: decodeFundingRequest.id,
metadata: getService.metadata,
payment: decodeFundingRequest.payment,
service: getService.service,
},
() => {
if (!!err) {
return cbk([503, 'UnexpectedErrorFindingExecutionRoute', {err}]);
}
if (!res.route) {
return cbk([503, 'FailedToFindAPathToPaySwapExecutionFee']);
}
});
}
return cbk(null, res.route);
});
}],
// Get peers of the destination node
getGateways: ['decodeFundingRequest', ({decodeFundingRequest}, cbk) => {
if (!!args.recovery) {
return cbk();
}
const finalKey = decodeFundingRequest.destination;
return getNode({lnd: args.lnd, public_key: finalKey}, (err, res) => {
if (!!err) {
return cbk(null, {});
}
const policies = res.channels.map(channel => {
return channel.policies.find(n => n.public_key !== finalKey);
});
const keys = uniq(policies.map(n => n.public_key));
const gateways = keys.filter(n => !!n).map(gateway => ({
from_public_key: gateway,
to_public_key: finalKey,
}));
return cbk(null, {gateways});
});
}],
// Get funding routes for a multiple path payment
getFundingRoutes: [
'decodeFundingRequest',
'getGateways',
'getIgnores',
'getService',
({decodeFundingRequest, getGateways, getIgnores, getService}, cbk) =>
{
if (!!args.recovery) {
return cbk();
}
// Exit early when not doing multipath funding
if (args.max_paths < 2) {
return cbk();
}
const hasFeatures = !!decodeFundingRequest.features.length;
const paths = [];
const sub = subscribeToMultiPathProbe({
allow_stacking: getGateways.gateways,
cltv_delta: decodeFundingRequest.cltv_delta + cltvBuffer,
destination: decodeFundingRequest.destination,
features: !!hasFeatures ? decodeFundingRequest.features : undefined,
ignore: getIgnores.ignore,
lnd: args.lnd,
logger: args.logger,
max_paths: args.max_paths || undefined,
routes: decodeFundingRequest.routes,
});
sub.on('error', err => {
return cbk([503, 'UnexpectedErrorProbingRouteToSwapService', {err}]);
});
sub.on('evaluating', ({tokens}) => {
return args.logger.info({evaluating: tokens});
});
sub.on('failure', () => {
return cbk([503, 'FailedToFindAnyPathsToSwapServiceDestination']);
});
sub.on('path', path => {
paths.push(path);
const liquidity = paths.reduce((m, n) => m + n.liquidity, Number());
return args.logger.info({
found_liquidity: formatTokens({tokens: liquidity}).display,
found_paths: paths.length,
});
});
sub.on('probing', async ({route}) => {
const {description} = await describeRoute({route, lnd: args.lnd});
return args.logger.info({probing: description});
});
sub.on('routing_failure', async failure => {
const {description} = await describeRoutingFailure({
index: failure.index,
lnd: args.lnd,
reason: failure.reason,
route: failure.route,
});
return args.logger.info({failure: description});
});
sub.on('success', ({paths}) => {
const liquidity = paths.reduce((m, n) => m + n.liquidity, Number());
if (decodeFundingRequest.tokens > liquidity) {
return cancelSwapOut({
id: decodeFundingRequest.id,
metadata: getService.metadata,
payment: decodeFundingRequest.payment,
service: getService.service,
},
() => {
return cbk([
503,
'FailedToFindEnoughLiquidityOnPathsToFundSwap',
{available_liquidity: liquidity},
]);
});
}
return cbk(null, {paths});
});
}],
// Get a funding route for a single path payment
findRoutesForFunding: [
'channel',
'decodeFundingRequest',
'findPeer',
'getFundingRoutes',
'getIgnores',
'getService',
'initiateSwap',
({
channel,
decodeFundingRequest,
findPeer,
getIgnores,
getService,
initiateSwap,
},
cbk) =>
{
if (!!args.recovery) {
return cbk();
}
// Exit early when doing multiple paths
if (args.max_paths > 1) {
return cbk();
}
const hasFeatures = !!decodeFundingRequest.features.length;
const {tokens} = decodeFundingRequest;
return getRoutesForFunding({
cltv_delta: decodeFundingRequest.cltv_delta + cltvBuffer,
destination: decodeFundingRequest.destination,
features: !!hasFeatures ? decodeFundingRequest.features : undefined,
ignore: getIgnores.ignore,
lnd: args.lnd,
logger: args.logger,
max_fee: round(tokens / maxRoutingFeeDenominator),
max_paths: args.max_paths || undefined,
mtokens: decodeFundingRequest.mtokens,
out_through: findPeer.public_key || undefined,
outgoing_channel: !!channel ? channel.id : undefined,
payment: decodeFundingRequest.payment,
request: initiateSwap.swap_fund_request,
routes: decodeFundingRequest.routes,
tokens: decodeFundingRequest.tokens,
},
(err, res) => {
if (!!err) {
return cancelSwapOut({
id: decodeFundingRequest.id,
metadata: getService.metadata,
payment: decodeFundingRequest.payment,
service: getService.service,
},
() => cbk(err, res));
}
return cbk(null, res);
});
}],
// Get info about the peer we are going to get inbound liquidity with
getSwapPeers: [
'findPeer',
'findRoutesForFunding',
'getChannels',
'getFundingRoutes',
'getPeerDetails',
({
findPeer,
findRoutesForFunding,
getChannels,
getFundingRoutes,
getPeerDetails,
},
cbk) =>
{
// Exit early when this is a recovery
if (!!args.recovery) {
return cbk();
}
// Exit early when a peer is specified
if (!!findPeer.public_key) {
return cbk(null, [{
alias: getPeerDetails.alias || undefined,
peer_channels: getChannels.channels.filter(channel => {
return channel.partner_public_key === findPeer.public_key;
}),
public_key: findPeer.public_key,
}]);
}
// Exit early when the funding routes are found
if (!!getFundingRoutes) {
return asyncMap(getFundingRoutes.paths, (path, cbk) => {
const [outPeer] = path.relays;
return getNode({
is_omitting_channels: true,
lnd: args.lnd,
public_key: outPeer,
},
(err, res) => {
return cbk(null, {
alias: !!res ? res.alias : undefined,
peer_channels: getChannels.channels.filter(channel => {
return channel.partner_public_key === outPeer;
}),
public_key: outPeer,
});
});
},
cbk);
}
return asyncMap(findRoutesForFunding.routes, (route, cbk) => {
const [firstHop] = route.hops;
return getNode({
is_omitting_channels: true,
lnd: args.lnd,
public_key: firstHop.public_key,
},
(err, res) => {
return cbk(null, {
alias: !!res ? res.alias : undefined,
peer_channels: getChannels.channels.filter(channel => {
return channel.partner_public_key === firstHop.public_key;
}),
public_key: firstHop.public_key,
});
});
},
cbk);
}],
// Get fee estimate for sweep
getMinSweepFee: [
'currency',
'decodeExecutionRequest',
'decodeFundingRequest',
'findRouteForExecution',
'findRoutesForFunding',
'getFundingRoutes',
'getLimits',
'getService',
'getSwapPeers',
'getQuote',
({
currency,
decodeExecutionRequest,
decodeFundingRequest,
findRouteForExecution,
findRoutesForFunding,
getFundingRoutes,
getLimits,
getService,
getSwapPeers,
},
cbk) =>
{
// Exit early when this is a recovery
if (!!args.recovery) {
return getChainFeeRate({
confirmation_target: maxCltvDelta,
lnd: args.lnd,
},
cbk);
}
const executionRoutingFee = findRouteForExecution.fee || Number();
const executionSend = decodeExecutionRequest.tokens;
const fundingRoutingFee = (findRoutesForFunding || {}).fee || Number();
const fundingSend = decodeFundingRequest.tokens;
const increase = `${tokensAsBigUnit(args.tokens)} ${currency}`;
const peerChannels = flatten(getSwapPeers.map(n => n.peer_channels));
const sumOf = tokens => tokens.reduce((sum, n) => sum + n, Number());
const peerIn = peerChannels.map(n => n.remote_balance);
const peerOut = peerChannels.map(n => n.local_balance);
const routingFees = executionRoutingFee + fundingRoutingFee;
const serviceFee = fundingSend + executionSend - args.tokens;
return getChainFeeRate({
confirmation_target: getLimits.max_cltv_delta,
lnd: args.lnd,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
const sweepFee = res.tokens_per_vbyte * estimatedSweepVbytes;
const allFees = ceil(serviceFee + sweepFee + routingFees);
if (!!args.max_fee && allFees > args.max_fee) {
return cbk([400, 'MaxFeeTooLowToExecuteSwap', {needed: allFees}]);
}
args.logger.info({
inbound_liquidity_increase: increase,
with_peers: getSwapPeers.map(n => `${n.alias} ${n.public_key}`),
swap_service_fee: `${tokensAsBigUnit(serviceFee)} ${currency}`,
estimated_total_fee: `${tokensAsBigUnit(allFees)} ${currency}`,
peers_inbound: `${tokensAsBigUnit(sumOf(peerIn))} ${currency}`,
peers_outbound: `${tokensAsBigUnit(sumOf(peerOut))} ${currency}`,
});
if (!!args.is_dry_run) {
return cancelSwapOut({
id: decodeFundingRequest.id,
metadata: getService.metadata,
payment: decodeFundingRequest.payment,
service: getService.service,
},
err => {
return cbk([500, 'InboundLiquidityIncreaseDryRun']);
});
}
return cbk(null, {
non_funding_routing_fees: allFees,
tokens_per_vbyte: res.tokens_per_vbyte
});
});
}],
// Multi pay to swap fund
multiPayToFund: [
'checkRequestAmounts',
'checkSwap',
'getFundingRoutes',
'getMinSweepFee',
'decodeFundingRequest',
({decodeFundingRequest, getFundingRoutes, getMinSweepFee}, cbk) =>
{
if (!getFundingRoutes) {
return cbk();
}
const sub = subscribeToMultiPathPay({
cltv_delta: decodeFundingRequest.cltv_delta + cltvDeltaBuffer,
destination: decodeFundingRequest.destination,
id: decodeFundingRequest.id,
lnd: args.lnd,
max_fee: args.max_fee - getMinSweepFee.non_funding_routing_fees,
mtokens: decodeFundingRequest.mtokens,
paths: getFundingRoutes.paths,
payment: decodeFundingRequest.payment,
routes: decodeFundingRequest.routes,
});
sub.on('error', err => {
return cbk([503, 'UnexpectedErrorPayingSwapFundingRequest', {err}]);
});
sub.on('failure', () => {
return cbk([503, 'FailedToPayFundingPaymentRequest']);
});
sub.on('paid', ({secret}) => args.logger.info({proof: secret}));
sub.on('paying', async ({route}) => {
const {description} = await describeRoute({route, lnd: args.lnd});
return args.logger.info({
amount: route.tokens,
paying: description,
});
});
sub.on('routing_failure', async ({index, reason, route}) => {
if (reason === 'MppTimeout') {
return;
}
const {description} = await describeRoutingFailure({
index,
reason,
route,
lnd: args.lnd,
});
return args.logger.info({failure: description});
});
sub.on('success', ({}) => cbk());
return;
}],
// Pay to swap funding
payToFund: [
'checkRequestAmounts',
'checkSwap',
'decodeFundingRequest',
'findRouteForExecution',
'findRoutesForFunding',
'getMinSweepFee',
'multiPayToFund',
({decodeFundingRequest, findRoutesForFunding}, cbk) =>
{
if (!!args.recovery) {
return cbk();
}
if (!findRoutesForFunding) {
return cbk();
}
args.logger.info({funding_swap: decodeFundingRequest.id});
return asyncMap(findRoutesForFunding.routes, (route, cbk) => {
return payViaRoutes({
id: decodeFundingRequest.id,
lnd: args.lnd,
routes: [route],
},
cbk);
},
(err, res) => {
if (!!err) {
return cbk([503, 'UnexpectedErrorFundingSwap', {err}]);
}
return cbk();
});
}],
// Pay to swap execution
payToExecute: [
'channel',
'checkRequestAmounts',
'checkSwap',
'decodeExecutionRequest',
'findRouteForExecution',
'findRoutesForFunding',
'getMinSweepFee',
'getQuote',
'initiateSwap',
({channel, decodeExecutionRequest, initiateSwap}, cbk) =>
{
// Exit early when the swap is in a recovery mode already
if (!!args.recovery) {
return cbk();
}
// Exit early when the swap is a dry run, do not pay execution request
if (!!args.is_dry_run) {
return cbk();
}
const fundAt = moment().add(swapDelayMinutes(args.is_fast), 'minutes');
args.logger.info({
paying_execution_request: decodeExecutionRequest.id,
estimated_swap_start_time: fundAt.calendar(),
});
const sub = subscribeToPayViaRequest({
lnd: args.lnd,
max_fee: maxExecutionFeeTokens,
outgoing_channel: !!channel ? channel.id : undefined,
pathfinding_timeout: maxPathfindingMs,
request: initiateSwap.swap_execute_request,
});
const finished = (err, res) => {
sub.removeAllListeners();
return cbk(err, res);
};
sub.once('confirmed', ({mtokens}) => finished(null, {mtokens}));
sub.once('end', () => finished([503, 'FailedToResolveSwapExecution']));
sub.once('error', err => {
return finished([503, 'UnexpectedErrorPayingFundingRequest', {err}]);
});
sub.once('failed', failed => {
if (!!failed.is_pathfinding_timeout) {
return finished([503, 'TimedOutFindingALightningRoute']);
}
return finished([503, 'UnexpectedOutcomeOfSwapFailure', {failed}]);
});
}],
// Look for deposit in mempool
findInMempool: [
'initiateSwap',
'network',
'recover',
'startHeight',
({initiateSwap, network, recover, startHeight}, cbk) =>
{
args.logger.info({waiting_for_swap_deposit_to: initiateSwap.address});
return findDeposit({
network,
address: initiateSwap.address,
after: startHeight - fuzzBlocks,
confirmations: [].length,
request: args.request,
timeout: maxPathfindingMs,
tokens: !!recover ? recover.tokens : args.tokens,
},
(err, res) => {
if (!!err) {
return cbk();
}
args.logger.info({swap_tx_confirming: res.transaction_id});
return cbk();
});
}],
// Look for deposit
findDeposit: [
'initiateSwap',
'network',
'recover',
'startHeight',
({initiateSwap, network, recover, startHeight}, cbk) =>
{
const sub = subscribeToBlocks({lnd: args.lnd});
const tokens = !recover ? args.tokens : recover.tokens;
sub.on('block', ({height}, cbk) => {
if (height <= startHeight) {
return;
}
return args.logger.info({blocks_waited: height - startHeight});
});
sub.on('error', err => args.logger.error({block_subscription: err}));
return findDeposit({
network,
tokens,
address: initiateSwap.address,
after: startHeight - fuzzBlocks,
confirmations: args.confs,
lnd: args.lnd,
timeout: args.timeout,
},
(err, res) => {
sub.removeAllListeners();
return cbk(err, res);
});
}],
// Check deposit
checkDeposit: ['findDeposit', ({findDeposit}, cbk) => {
if (!!args.recovery) {
return cbk();
}
if (findDeposit.output_tokens < args.tokens) {
return cbk([503, 'ExpectedLargerDepositForSwapFundingDeposit']);
}
return cbk();
}],
// Register deposit height
depositHeight: ['findDeposit', ({findDeposit}, cbk) => {
if (!!args.recovery) {
return cbk();
}
return getHeight({lnd: args.lnd}, (err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, res.current_block_height);
});
}],
// Claim details
claim: [
'findDeposit',
'initiateSwap',
({findDeposit, initiateSwap}, cbk) =>
{
return cbk(null, {
private_key: initiateSwap.private_key,
script: initiateSwap.script,
secret: initiateSwap.secret,
timeout: initiateSwap.timeout,
transaction_id: findDeposit.transaction_id,
transaction_vout: findDeposit.transaction_vout,
});
}],
// Raw recovery
rawRecovery: [
'claim',
'createAddress',
'depositHeight',
'initiateSwap',
'network',
'recover',
'sends',
'startHeight',
({
claim,
createAddress,
depositHeight,
initiateSwap,
network,
recover,
sends,
startHeight,
},
cbk) =>
{
// Exit early when the raw recovery option is not toggled
if (!args.is_raw_recovery_shown) {
return cbk();
}
return getRawRecoveries({
network,
sends,
confs: args.confs,
deposit_height: depositHeight,
lnd: args.lnd,
max_wait_blocks: args.max_wait_blocks,
private_key: claim.private_key,
script: claim.script,
secret: claim.secret,
start_height: startHeight,
sweep_address: createAddress.address,
timeout: initiateSwap.timeout,
tokens: !recover ? args.tokens : recover.tokens,
transaction_id: claim.transaction_id,
transaction_vout: claim.transaction_vout,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
res.recoveries.forEach(recovery => args.logger.info(recovery));
return cbk();
});
}],
// Execute the sweep
sweep: [
'claim',
'createAddress',
'depositHeight',
'getMinSweepFee',
'getService',
'initiateSwap',
'network',
'rawRecovery',
'recover',
'sends',
'startHeight',
({
claim,
createAddress,
depositHeight,
getMinSweepFee,
getService,
initiateSwap,
network,
recover,
sends,
startHeight,
},
cbk) =>
{
const blocksUntilTimeout = initiateSwap.timeout - startHeight;
if (blocksUntilTimeout < args.confs) {
return cbk([503, 'FailedToReceiveSwapFundingConfirmationInTime']);
}
args.logger.info({swap_deposit_confirmed: claim.transaction_id});
const blocksSubscription = subscribeToBlocks({lnd: args.lnd});
const tokens = !recover ? args.tokens : recover.tokens;
blocksSubscription.on('end', () => {});
blocksSubscription.on('error', () => {});
blocksSubscription.on('status', () => {});
const startingHeight = depositHeight || initiateSwap.start_height;
// On every block, attempt a sweep
blocksSubscription.on('block', ({height}) => {
return attemptSweep({
network,
sends,
tokens,
current_height: height,
deadline_height: initiateSwap.timeout - args.confs,
lnd: args.lnd,
max_fee_multiplier: maxFeeMultiplier,
min_fee_rate: getMinSweepFee.tokens_per_vbyte,
private_key: claim.private_key,
request: args.request,
secret: claim.secret,
start_height: startingHeight - fuzzBlocks,
sweep_address: createAddress.address,
transaction_id: claim.transaction_id,
transaction_vout: claim.transaction_vout,
witness_script: claim.script,
},
(err, res) => {
return setTimeout(() => {
// Exit early when the listener count is low
if (!blocksSubscription.listenerCount('block')) {
return;
}
if (!!err) {
return args.logger.error({
message: 'AttemptedSweep',
spending: !!args.recovery ? claim.transaction_id : undefined,
});
}
args.logger.info({
attempting_sweep_fee_rate: res.fee_rate,
attempt_tx_id: Transaction.fromHex(res.transaction).getId(),
});
createInvoice({
description: hexAsBase64(res.transaction),
expires_at: farFutureDate(),
lnd: args.lnd,
},
err => {
// Suppress errors creating backup invoice
return;
});
// Exit early when the swap service is not available
if (!getService) {
return;
}
return releaseSwapOutSecret({
metadata: getService.metadata,
secret: claim.secret,
service: getService.service,
},
err => {
// Suppress errors releasing secret
return;
});
},
sweepProgressLogDelayMs);
});
});
return findDeposit({
network,
address: createAddress.address,
after: startHeight,
confirmations: max(args.confs, minSweepConfs),
lnd: args.lnd,
timeout: args.timeout,
transaction_id: claim.transaction_id,
transaction_vout: claim.transaction_vout,
},
(err, res) => {
blocksSubscription.removeAllListeners();
if (!!err) {
return cbk(err);
}
return cbk(null, {output_tokens: res.output_tokens});
});
}],
// Get funding payment
getFundingPayment: [
'decodeFundingRequest',
'multiPayToFund',
'payToFund',
'recover',
'sweep',
({decodeFundingRequest, recover}, cbk) =>
{
const fundingRequest = decodeFundingRequest || {};
const id = fundingRequest.id || sha256(recover.secret).digest('hex');
const sub = subscribeToPastPayment({id, lnd: args.lnd});
const finished = (err, res) => {
sub.removeAllListeners();
return cbk(err, res);
};
sub.once('confirmed', payment => {
if (!!fundingRequest.id) {
args.logger.info({
inbound_liquidity_increase: tokensAsBigUnit(payment.safe_tokens),
});
}
return finished(null, {payment});
});
sub.once('failed', failed => {
if (!!failed.is_pathfinding_timeout) {
return cbk([503, 'TimedOutTryingToFindPathToSwapService']);
}
return cbk([503, 'UnableToFindAnyPathToSwapService']);
});
return;
}],
// Get execution payment
getExecutionPayment: [
'decodeExecutionRequest',
'payToExecute',
'recover',
({decodeExecutionRequest, recover}, cbk) =>
{
const executionRequest = decodeExecutionRequest || {};
const i