paid-services
Version:
Lightning Paid Services library
494 lines (432 loc) • 14.5 kB
JavaScript
const {address} = require('bitcoinjs-lib');
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const {findKey} = require('ln-sync');
const {getMasterPublicKeys} = require('ln-service');
const {getAllInvoices} = require('ln-sync');
const {getInvoice} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getNodeAlias} = require('ln-sync');
const {networks} = require('bitcoinjs-lib');
const {returnResult} = require('asyncjs-util');
const decodeOffToOnRequest = require('./decode_off_to_on_request');
const decodeOffToOnRestart = require('./decode_off_to_on_restart');
const {decodeTrade} = require('./../trades');
const outputScriptForAddress = require('./output_script_for_address');
const recoverResponseToSwapOut = require('./recover_response_to_swap_out');
const requestSwapOut = require('./request_swap_out');
const respondToSwapOutRequest = require('./respond_to_swap_out_request');
const actionLoopRequest = 'loop-request';
const actionPushRequest = 'push-request';
const actionRestartRequest = 'restart-request';
const allowedNetwork = 'btctestnet';
const bip86Path = `m/86'/0'/0'`;
const flatten = arr => [].concat(...arr);
const minConfirmations = {btc: 2, btcregtest: 3, btctestnet: 1};
const niceId = id => id.substring(0, 8);
const niceName = node => `${node.alias} ${node.id.substring(0, 8)}`.trim();
const pushesReceivedAfter = () => new Date(Date.now() - 1000 * 60 * 60 * 24);
const recoverRespondAction = 'recover-response';
const requestAction = 'request';
const respondAction = 'respond';
const {toOutputScript} = address;
const tokensAsBigUnit = tokens => (tokens / 1e8).toFixed(8);
const typeKeySendTrade = '805805';
/** Manage a swap
{
ask: <Ask Function>
lnd: <Authenticated LND API Object>
logger: <Winston Logger Object>
request: <Request Function>
}
@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, 'ExpectedAskFunctionToManageSwap']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToManageSwap']);
}
if (!args.logger) {
return cbk([400, 'ExpectedWinstonLoggerToManageSwap']);
}
if (!args.request) {
return cbk([400, 'ExpectedRequestFunctionToManageSwap']);
}
return cbk();
},
// Get invoices to find past swap requests
getInvoices: ['validate', ({}, cbk) => {
return getAllInvoices({
created_after: pushesReceivedAfter().toISOString(),
lnd: args.lnd,
},
cbk);
}],
// Look for a p2tr master public key to detect p2tr support
getMasterPublicKeys: ['validate', ({}, cbk) => {
return getMasterPublicKeys({lnd: args.lnd}, cbk);
}],
// Get the network to make sure this is on testnet
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Received swap pushes
swapPushes: ['getInvoices', ({getInvoices}, cbk) => {
const pushes = getInvoices.invoices.map(invoice => {
// A swap push is a KeySend payment
if (!invoice.is_confirmed || !invoice.is_push) {
return;
}
const messages = flatten(invoice.payments.map(n => n.messages));
// Convert messages to swaps
const swaps = messages.map(message => {
// Swaps have the trade type wrapper
if (message.type !== typeKeySendTrade) {
return;
}
// The trade type should be a swap
try {
const {swap} = decodeTrade({trade: message.value});
if (!swap) {
return;
}
const details = decodeOffToOnRequest({request: swap.request});
// Return relevant swap details for acceptance
return {
confirmed_at: invoice.confirmed_at,
id: details.hash,
tokens: details.tokens,
node: swap.node,
trade: message.value,
};
} catch (err) {
return;
}
});
const [swap] = swaps.filter(n => !!n);
return swap;
});
return cbk(null, pushes.filter(n => !!n));
}],
// Get node aliases for pushes
getAliases: ['swapPushes', ({swapPushes}, cbk) => {
return asyncMap(swapPushes, ({node}, cbk) => {
return getNodeAlias({id: node, lnd: args.lnd}, cbk);
},
cbk);
}],
// Get existing swaps
getExisting: ['swapPushes', ({swapPushes}, cbk) => {
return asyncMap(swapPushes, ({id}, cbk) => {
return getInvoice({id, lnd: args.lnd}, (err, res) => {
// Error indicates that the invoice could not be looked up
if (!!err) {
return cbk();
}
// Result indicates the invoice is known so the swap is in progress
return cbk(null, id);
});
},
cbk);
}],
// Determine if taproot is supported or not
hasTr: ['getMasterPublicKeys', ({getMasterPublicKeys}, cbk) => {
const {keys} = getMasterPublicKeys;
return cbk(null, !!keys.find(n => n.derivation_path === bip86Path));
}],
// Select a swap action
selectAction: [
'getAliases',
'getExisting',
'getNetwork',
'hasTr',
'swapPushes',
({getAliases, getExisting, getNetwork, hasTr, swapPushes}, cbk) =>
{
// Check to make sure the network is supported
if (!hasTr && getNetwork.network !== allowedNetwork) {
return cbk([501, 'CurrentlyUnsupportedChainForSwap']);
}
const pushes = swapPushes
.filter(push => !getExisting.includes(push.id))
.map(push => {
const amount = tokensAsBigUnit(push.tokens);
const id = niceId(push.id);
const node = niceName(getAliases.find(n => n.id === push.node));
return {
name: `Accept request ${id} to swap ${amount} with ${node}`,
value: push.trade,
};
});
const choices = [
{
name: 'Make a new swap request',
value: requestAction,
},
{
name: 'Send a new swap request via KeySend',
value: actionPushRequest,
},
{
name: 'Request a swap from Lightning Loop (experimental)',
value: actionLoopRequest,
},
{
name: 'Respond to a swap request',
value: respondAction,
},
{
name: 'Recovery for swap request',
value: actionRestartRequest,
},
{
name: 'Recovery for swap response',
value: recoverRespondAction,
},
];
return args.ask({
choices: choices.concat(pushes),
message: 'Off-chain to on-chain swap:',
name: 'action',
type: 'list',
},
({action}) => cbk(null, action));
}],
// Ask for the remote to send to
askForRemote: ['selectAction', ({selectAction}, cbk) => {
// Exit early when the request is not KeySend
if (selectAction !== actionPushRequest) {
return cbk();
}
return args.ask({
message: 'Node key or channel peer alias to send request to?',
name: 'remote',
validate: input => !!input,
},
({remote}) => cbk(null, remote));
}],
// Ask for a request restart code
askForRestart: ['selectAction', ({selectAction}, cbk) => {
// Exit early when not restarting a swap request
if (selectAction !== actionRestartRequest) {
return cbk();
}
return args.ask({
message: 'Restart recovery code?',
name: 'restart',
validate: input => !!input,
},
({restart}) => cbk(null, restart));
}],
// Find the remote identity key when specified
findKey: ['askForRemote', ({askForRemote}, cbk) => {
if (!askForRemote) {
return cbk(null, {});
}
return findKey({lnd: args.lnd, query: askForRemote}, cbk);
}],
// Ask if the offchain funding should be external
askForExternal: [
'askForRemote',
'selectAction',
({selectAction}, cbk) =>
{
switch (selectAction) {
case actionLoopRequest:
case actionPushRequest:
case requestAction:
return args.ask({
message: 'Use internal funds to pay offchain payment request?',
name: 'internal',
type: 'confirm',
},
({internal}) => cbk(null, !internal));
default:
// External funding not supported
return cbk();
}
}],
// Ask if the sweep address should be custom
askForSweepAddress: [
'askForExternal',
'getNetwork',
'selectAction',
({getNetwork, selectAction}, cbk) =>
{
switch (selectAction) {
case actionLoopRequest:
case actionPushRequest:
case requestAction:
return args.ask({
message: 'Send on-chain funds to an external address? (Optional)',
name: 'sweep',
type: 'input',
validate: input => {
if (!input) {
return true;
}
try {
return !!outputScriptForAddress({
address: input,
network: getNetwork.bitcoinjs,
});
} catch (err) {
return 'Unsupported on-chain address format';
}
}
},
({sweep}) => cbk(null, sweep));
default:
// Custom sweep address not supported
return cbk();
}
}],
// Make swap request
makeRequest: [
'askForExternal',
'askForRemote',
'askForRestart',
'askForSweepAddress',
'findKey',
'getNetwork',
'hasTr',
'selectAction',
({
askForExternal,
askForRestart,
askForRemote,
askForSweepAddress,
findKey,
getNetwork,
hasTr,
selectAction,
},
cbk) =>
{
switch (selectAction) {
case actionLoopRequest:
case requestAction:
break;
case actionPushRequest:
if (!findKey.public_key) {
return cbk([400, 'UnknownNodeToPushSwapRequestTo']);
}
break;
case actionRestartRequest:
const restart = decodeOffToOnRestart({restart: askForRestart});
return requestSwapOut({
ask: args.ask,
fund_routing_fee_rate: restart.max_fund_fee_rate,
is_external_funding: restart.is_external_funding,
is_loop_service: restart.is_loop_service,
lnd: args.lnd,
logger: args.logger,
min_confirmations: restart.min_confirmations,
request: !hasTr ? args.request : undefined,
swap_recovery: restart.recovery,
swap_request: restart.request,
swap_response: restart.response,
sweep_address: restart.sweep_address,
},
cbk);
default:
return cbk();
}
return requestSwapOut({
ask: args.ask,
is_external_funding: askForExternal,
is_loop_service: selectAction === actionLoopRequest,
lnd: args.lnd,
logger: args.logger,
push_to: findKey.public_key,
min_confirmations: minConfirmations[getNetwork.network],
request: !hasTr ? args.request : undefined,
sweep_address: askForSweepAddress || undefined,
},
cbk);
}],
// Make a response for a swap request
makeResponse: ['hasTr', 'selectAction', ({hasTr, selectAction}, cbk) => {
if (selectAction !== respondAction) {
return cbk();
}
return respondToSwapOutRequest({
ask: args.ask,
is_external_funding: true,
lnd: args.lnd,
logger: args.logger,
request: !hasTr ? args.request : undefined,
},
cbk);
}],
// Respond to a pushed swap request
respondToPush: [
'hasTr',
'selectAction',
({hasTr, selectAction}, cbk) =>
{
// Exit early when not responding to a push
try {
decodeTrade({trade: selectAction});
} catch (err) {
return cbk();
}
const {swap} = decodeTrade({trade: selectAction});
return respondToSwapOutRequest({
ask: args.ask,
lnd: args.lnd,
logger: args.logger,
request: !hasTr ? args.request : undefined,
swap: swap.request,
to: swap.node,
},
cbk);
}],
// Recover responding to swap request
recoverResponse: [
'hasTr',
'selectAction',
({hasTr, selectAction}, cbk) =>
{
// Exit early when not recovering on the response side
if (selectAction !== recoverRespondAction) {
return cbk();
}
return recoverResponseToSwapOut({
ask: args.ask,
lnd: args.lnd,
logger: args.logger,
request: !hasTr ? args.request : undefined,
},
cbk);
}],
// Final result
result: [
'makeRequest',
'makeResponse',
'recoverResponse',
'respondToPush',
({makeRequest, makeResponse, recoverResponse, respondToPush}, cbk) =>
{
if (!!makeRequest) {
return cbk(null, makeRequest);
}
if (!!makeResponse) {
return cbk(null, makeResponse);
}
if (!!recoverResponse) {
return cbk(null, recoverResponse);
}
if (!!respondToPush) {
return cbk(null, respondToPush);
}
return cbk();
}],
},
returnResult({reject, resolve, of: 'result'}, cbk));
});
};