balanceofsatoshis
Version:
Lightning balance CLI
595 lines (504 loc) • 16.4 kB
JavaScript
const {createHash} = require('crypto');
const {addressForScript} = require('goldengate');
const asyncAuto = require('async/auto');
const {broadcastTransaction} = require('ln-sync');
const {cancelHodlInvoice} = require('ln-service');
const {createChainAddress} = require('ln-service');
const {createHodlInvoice} = require('ln-service');
const {createInvoice} = require('ln-service');
const {createSwapIn} = require('goldengate');
const {decodeSwapRecovery} = require('goldengate');
const {encodeSwapRecovery} = require('goldengate');
const {findDeposit} = require('goldengate');
const {findSecret} = require('goldengate');
const {getChainFeeRate} = require('goldengate');
const {getHeight} = require('ln-service');
const {getInvoice} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getSwapInQuote} = require('goldengate');
const {getSwapInTerms} = require('goldengate');
const moment = require('moment');
const qrcode = require('qrcode-terminal');
const {refundTransaction} = require('goldengate');
const {returnResult} = require('asyncjs-util');
const {subscribeToBlocks} = require('goldengate');
const {subscribeToInvoice} = require('ln-service');
const {subscribeToSwapInStatus} = require('goldengate');
const {swapInFee} = require('goldengate');
const {authenticatedLnd} = require('./../lnd');
const {getLiquidity} = require('./../balances');
const getPaidService = require('./get_paid_service');
const bigFormat = tokens => ((tokens || 0) / 1e8).toFixed(8);
const msPerBlock = 1000 * 60 * 10;
const msPerYear = 1000 * 60 * 60 * 24 * 365;
const {now} = Date;
const sha256 = buffer => createHash('sha256').update(buffer).digest();
const waitForDepositMs = 1000 * 60 * 60 * 24;
/** Receive funds on-chain
{
[api_key]: <API Key CBOR Hex String>
fetch: <Fetch Function>
[in_through]: <Request Inbound Payment Public Key Hex String>
[is_refund_test]: <Alter Swap Timeout To Have Short Refund Bool>
lnd: <Authenticated LND gRPC API Object>
logger: <Logger Object>
[max_fee]: <Maximum Fee Tokens to Pay Number>
[recovery]: <Recover In-Progress Swap String>
[refund_address]: <Refund Address String>
[request]: <Request Function>
[socket]: <Swap Socket String>
[tokens]: <Tokens Number>
}
@returns via cbk
{
address: <Address String>
}
*/
module.exports = (args, cbk) => {
let isSuccessfulSwap = false;
return asyncAuto({
// Check arguments
validate: cbk => {
if (!args.fetch) {
return cbk([400, 'ExpectedFetchFunctionToReceiveOnChain']);
}
if (!args.fs) {
return cbk([400, 'ExpectedFileSystemMethodsToReceiveOnChain']);
}
if (!args.logger) {
return cbk([400, 'ExpectedLoggerToReceiveOnChain']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedLndToReceiveOnChain']);
}
if (!args.tokens && !args.recovery) {
return cbk([400, 'ExpectedTokensAmountToReceiveOnChain']);
}
return cbk();
},
// Get the best block height at the start of the swap
getInfo: ['validate', ({}, cbk) => getHeight({lnd: args.lnd}, cbk)],
// Get channels
getLiquidity: ['validate', ({}, cbk) => {
// Exit early when recovering from an existing swap
if (!!args.recovery) {
return cbk();
}
return getLiquidity({
above: args.tokens,
fs: args.fs,
is_top: true,
lnd: args.lnd,
},
cbk);
}],
// Get the network this swap takes place on
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Upgrade service object to a paid service if necessary
getService: ['getLiquidity', 'getNetwork', ({}, cbk) => {
// Exit early when we're recovering an existing swap
if (!!args.recovery && !args.api_key) {
return cbk();
}
return getPaidService({
fetch: args.fetch,
lnd: args.lnd,
logger: args.logger,
socket: args.socket,
token: args.api_key,
},
cbk);
}],
// Get the limits for a swap
getLimits: ['getService', ({getService}, cbk) => {
// Exit early when recovering an existing swap
if (!!args.recovery) {
return cbk();
}
return getSwapInTerms({
metadata: getService.metadata,
service: getService.service,
},
cbk);
}],
// Get quote for a swap
getQuote: [
'getLiquidity',
'getService',
({getLiquidity, getService}, cbk) =>
{
// Exit early when recovering an existing swap
if (!!args.recovery) {
return cbk();
}
// Exit early when there is insufficient inbound liquidity
if (!getLiquidity.balance) {
return cbk([400, 'InsufficientInboundLiquidityToReceiveSwapOffchain']);
}
return getSwapInQuote({
metadata: getService.metadata,
service: getService.service,
tokens: args.tokens,
},
cbk);
}],
// Create an invoice
createInvoice: ['getLimits', 'getQuote', ({getLimits, getQuote}, cbk) => {
// Exit early when we're recovering an existing swap
if (!!args.recovery) {
return (async () => {
try {
const {id} = await decodeSwapRecovery({recovery: args.recovery});
return getInvoice({id, lnd: args.lnd}, cbk);
} catch (err) {
return cbk([400, 'FailedToDecodeSwapRecovery', {err}]);
}
})();
}
if (args.tokens > getLimits.max_tokens) {
return cbk([400, 'AmountTooHighToSwap', {max: getLimits.max_tokens}]);
}
if (args.tokens < getLimits.min_tokens) {
return cbk([400, 'AmountTooLowToSwap', {min: getLimits.min_tokens}]);
}
const {fee} = getQuote;
if (!!args.max_fee && fee > args.max_fee) {
return cbk([400, 'MaxFeeExceededForSwap', {required_fee: fee}]);
}
return createInvoice({
description: `Submarine swap. Service fee: ${fee}`,
expires_at: new Date(now() + msPerYear).toISOString(),
is_including_private_channels: true,
lnd: args.lnd,
tokens: args.tokens - fee,
},
cbk);
}],
// Create probe invoice
createProbeInvoice: ['createInvoice', ({createInvoice}, cbk) => {
// Exit early when we're recovering an existing swap
if (!!args.recovery) {
return cbk();
}
// The probe invoice has a hash deterministically derived from the swap
const hashedHash = sha256(Buffer.from(createInvoice.id, 'hex'));
hashedHash[0] ^= 1;
return createHodlInvoice({
description: 'Dummy invoice for swap probe',
expires_at: moment().add(1, 'hour').toISOString(),
id: hashedHash.toString('hex'),
is_including_private_channels: true,
lnd: args.lnd,
mtokens: createInvoice.mtokens,
},
cbk);
}],
// Wait for the probe invoice to be hit
waitForProbe: ['createProbeInvoice', ({createProbeInvoice}, cbk) => {
// Exit early when there is no probe invoice to wait for
if (!createProbeInvoice) {
return cbk();
}
const sub = subscribeToInvoice({
id: createProbeInvoice.id,
lnd: args.lnd,
});
args.logger.info({evaluating_connectivity: true});
sub.on('error', err => cbk(err));
sub.on('invoice_updated', invoice => {
if (!!invoice.is_canceled) {
return cbk([503, 'FailedToReceiveProbe']);
}
// Exit early when waiting for probe
if (!invoice.is_held) {
return;
}
sub.removeAllListeners();
// Cancel back the invoice when it is held
return cancelHodlInvoice({id: invoice.id, lnd: args.lnd}, () => cbk());
});
return;
}],
// Initiate the swap
createSwap: [
'createInvoice',
'createProbeInvoice',
'getQuote',
'getService',
({createInvoice, createProbeInvoice, getQuote, getService}, cbk) =>
{
// Exit early when we're recovering an existing swap
if (!!args.recovery) {
return cbk();
}
if (!!getService.paid && !!getService.token) {
args.logger.info({
amount_paid_for_api_key: bigFormat(getService.paid),
service_api_key: getService.token,
});
}
return createSwapIn({
fee: getQuote.fee,
in_through: args.in_through,
metadata: getService.metadata,
probe_request: createProbeInvoice.request,
request: createInvoice.request,
service: getService.service,
},
cbk);
}],
// Swap details
swap: [
'createSwap',
'getInfo',
'getNetwork',
async ({createSwap, getInfo, getNetwork}) =>
{
// Exit early when no swap is taking place
if (!args.recovery && !createSwap) {
return;
}
const {network} = getNetwork;
// Exit early when recovery details are specified
if (!!args.recovery) {
const recovery = await decodeSwapRecovery({recovery: args.recovery});
const {address} = addressForScript({network, script: recovery.script});
return {
address,
claim_public_key: recovery.claim_public_key,
id: recovery.id,
refund_private_key: recovery.refund_private_key,
script: recovery.script,
start_height: recovery.start_height,
timeout: recovery.timeout,
tokens: recovery.tokens,
version: recovery.version,
};
}
const isTest = !!args.is_refund_test;
const cltv = !isTest ? createSwap.timeout : getInfo.current_block_height;
return {
address: createSwap.address,
claim_public_key: createSwap.service_public_key,
id: createSwap.id,
refund_private_key: createSwap.private_key,
script: createSwap.script,
start_height: getInfo.current_block_height,
timeout: cltv,
tokens: createSwap.tokens,
version: createSwap.version,
};
}],
// Create a regular address
chainAddress: ['swap', ({swap}, cbk) => {
return createChainAddress({
format: 'p2wpkh',
is_unused: true,
lnd: args.lnd,
},
cbk);
}],
// Recovery
recovery: ['getInfo', 'swap', ({getInfo, swap}, cbk) => {
if (!!args.recovery || !swap) {
return cbk();
}
try {
const {recovery} = encodeSwapRecovery({
claim_public_key: swap.claim_public_key,
id: swap.id,
refund_private_key: swap.refund_private_key,
start_height: getInfo.current_block_height,
timeout: swap.timeout,
tokens: swap.tokens,
version: swap.version,
});
return cbk(null, recovery);
} catch (err) {
return cbk([500, 'FailedToGenerateSwapRecovery', {err}])
}
}],
// Find in deposit in mempool
findDepositInMempool: [
'createInvoice',
'getNetwork',
'swap',
({createInvoice, getNetwork, swap}, cbk) =>
{
// Exit early when there is no outstanding invoice
if (!createInvoice || !!createInvoice.is_confirmed) {
return cbk();
}
return findDeposit({
address: swap.address,
after: swap.start_height,
confirmations: [].length,
network: getNetwork.network,
request: args.request,
timeout: waitForDepositMs,
tokens: swap.tokens,
},
(err, res) => {
if (!!err) {
return cbk();
}
args.logger.info({waiting_for_confirmation_of_tx: res.transaction_id});
return cbk();
});
}],
// Find deposit
findDeposit: [
'createInvoice',
'getNetwork',
'swap',
({createInvoice, getNetwork, swap}, cbk) =>
{
if (!createInvoice || !!createInvoice.is_confirmed) {
return cbk();
}
return findDeposit({
address: swap.address,
after: swap.start_height,
confirmations: [].length,
lnd: args.lnd,
network: getNetwork.network,
timeout: waitForDepositMs,
tokens: swap.tokens,
},
cbk);
}],
// Get chain fee rate
getFeeRate: ['swap', ({swap}, cbk) => {
return getChainFeeRate({
confirmation_target: swap.timeout,
lnd: args.lnd,
},
cbk);
}],
// Refund transaction
refund: [
'chainAddress',
'findDeposit',
'getFeeRate',
'getNetwork',
'swap',
({chainAddress, findDeposit, getFeeRate, getNetwork, swap}, cbk) =>
{
if (!findDeposit || !!isSuccessfulSwap) {
return cbk();
}
const {transaction} = refundTransaction({
block_height: swap.timeout,
fee_tokens_per_vbyte: getFeeRate.tokens_per_vbyte,
is_nested: false,
network: getNetwork.network,
private_key: swap.refund_private_key,
sweep_address: args.refund_address || chainAddress.address,
tokens: swap.tokens,
transaction_id: findDeposit.transaction_id,
transaction_vout: findDeposit.transaction_vout,
witness_script: swap.script,
});
args.logger.info({
refund_height: swap.timeout,
refund_transaction: transaction,
});
return cbk(null, transaction);
}],
// Broadcast refund transaction
broadcastRefund: [
'findDepositInMempool',
'getInfo',
'refund',
'swap',
({getInfo, refund, swap}, cbk) =>
{
if (!args.recovery || !refund) {
return cbk();
}
const blocks = swap.timeout - getInfo.current_block_height;
if (blocks > [].length) {
args.logger.info({
refund_possible: moment(now() + msPerBlock * blocks).fromNow(),
blocks_left_until_refund_can_be_broadcast: blocks,
});
return cbk();
}
return broadcastTransaction({
lnd: args.lnd,
logger: args.logger,
transaction: refund,
},
cbk);
}],
// Refund broadcast
refundBroadcast: [
'broadcastRefund',
'getInfo',
'swap',
({broadcastRefund, getInfo, swap}, cbk) =>
{
if (!args.recovery || !broadcastRefund) {
return cbk();
}
args.logger.info({
refund_transaction_id: broadcastRefund.transaction_id,
});
return cbk();
}],
// Wait for payment
waitForPayment: [
'createInvoice',
'getInfo',
'getNetwork',
'recovery',
'swap',
({createInvoice, getInfo, getNetwork, recovery, swap}, cbk) =>
{
if (!createInvoice || !swap || !!args.recovery) {
return cbk();
}
const expiryBlocks = swap.timeout - getInfo.current_block_height;
let foundTx = false;
const startHeight = getInfo.current_block_height;
const url = `bitcoin:${swap.address}?amount=${bigFormat(args.tokens)}`;
qrcode.generate(url, {small: true}, qr => {
return args.logger.info({
swap: {
send_to_address: swap.address,
send_exact_amount: bigFormat(args.tokens),
send_to_qr: qr,
},
swap_service_fee: args.tokens - createInvoice.tokens,
refund_recovery_secret: recovery,
timing: {
earliest_completion: moment(now() + msPerBlock).fromNow(),
refund_available: moment(now() + expiryBlocks*msPerBlock).fromNow(),
},
});
});
const sub = subscribeToInvoice({id: createInvoice.id, lnd: args.lnd});
const finished = (err, res) => {
sub.removeAllListeners();
return cbk(err, res);
};
sub.on('error', err => finished(err));
sub.on('invoice_updated', invoice => {
if (!invoice.is_confirmed) {
return;
}
isSuccessfulSwap = true;
args.logger.info({
swap_successful: {
completed: moment(invoice.confirmed_at).calendar(),
received_offchain: bigFormat(invoice.received),
service_fee_paid: bigFormat(args.tokens - invoice.received),
},
});
return finished();
});
return;
}],
},
returnResult({}, cbk));
};