UNPKG

ln-service

Version:

Interaction helper for your Lightning Network daemon

559 lines (480 loc) 15.8 kB
const {join} = require('path'); const {readFile} = require('fs'); const {readFileSync} = require('fs'); const {spawn} = require('child_process'); const asyncAuto = require('async/auto'); const asyncMap = require('async/map'); const asyncMapSeries = require('async/mapSeries'); const asyncRetry = require('async/retry'); const {networks} = require('bitcoinjs-lib'); const openPortFinder = require('portfinder'); const tinysecp = require('tiny-secp256k1'); const {authenticatedLndGrpc} = require('./../../'); const {createSeed} = require('./../../'); const {createWallet} = require('./../../'); const generateBlocks = require('./generate_blocks'); const {getWalletInfo} = require('./../../'); const spawnChainDaemon = require('./spawn_chain_daemon'); const {subscribeToWalletStatus} = require('./../../'); const {unauthenticatedLndGrpc} = require('./../../'); const adminMacaroonFileName = 'admin.macaroon'; const chainPass = '0k39BVOdg4uuS7qNCG2jbIXNpwU7d3Ft87PpHPPoCfk='; const chainRpcCertName = 'rpc.cert'; const chainUser = 'bitcoinrpc'; const interval = retryCount => 50 * Math.pow(2, retryCount); const invoiceMacaroonFileName = 'invoice.macaroon'; const {isArray} = Array; const lightningDaemonExecFileName = 'lnd'; const lightningDaemonLogPath = 'logs/'; const lightningSeedPassphrase = 'passphrase'; const lightningTlsCertFileName = 'tls.cert'; const lightningTlsKeyFileName = 'tls.key'; const lightningWalletPassword = 'password'; const lndWalletUnlockerService = 'Unlocker'; const localhost = '127.0.0.1'; const maxSpawnChainDaemonAttempts = 10; const {random} = Math; const readMacaroonFileName = 'readonly.macaroon'; const retryCreateSeedCount = 5; const {round} = Math; const startPortRange = 7593; const startWalletTimeoutMs = 5500; const times = 30; /** Spawn an LND instance { [circular]: <Allow Circular Payments Bool> [intercept]: <Enable RPC Interception Bool> [keysend]: <Enable Key Send Bool> [noauth]: <Disable Macaroon Bool> [seed]: <Seed Phrase String> [tower]: <Tower Enabled Bool> [watchers]: <Watchtower Client Enabled Bool> } @returns via cbk { chain_listen_port: <Chain Listen Port Number> chain_rpc_cert: <RPC Cert Path String> chain_rpc_pass: <Chain RPC Password String> chain_rpc_port: <RPC Port Number> chain_rpc_user: <Chain RPC Username String> kill: <Stop Function> ({}, err => {}) listen_ip: <Listen Ip String> listen_port: <Listen Port Number> lnd: <LND GRPC API Object> lnd_cert: <LND Base64 Encoded TLS Certificate String> lnd_macaroon: <LND Base64 Encoded Authentication Macaroon String> lnd_socket: <LND RPC Socket String> mining_key: <Mining Rewards Private Key WIF Encoded String> public_key: <Node Public Key Hex String> rpc_port: <RPC Port Number> seed: <Node Seed Phrase String> socket: <LND RPC Network Socket String> } */ module.exports = (args, cbk) => { return asyncAuto({ // Import ECPair library ecp: async () => (await import('ecpair')).ECPairFactory(tinysecp), // Find open ports for the listen, REST and RPC ports getPorts: cbk => { return asyncRetry({interval: n => round(random() * 100), times: 1000}, cbk => { let i = 0; const ports = ['listen', 'rest', 'rpc', 'tower']; return asyncMapSeries(ports, (_, cbk) => { const port = startPortRange + (++i * 1000) + round(random() * 1000); const stopPort = port + 20000; return setTimeout(() => { return openPortFinder.getPort({port, stopPort}, cbk); }, round(random() * 100)); }, (err, ports) => { if (!!err || !isArray(ports) || !ports.length) { return setTimeout(() => { return cbk([500, 'FailedToFindOpenPortsWhenSpawningLnd', {err}]); }, round(random() * 1000)); } const [listen, rest, rpc, tower] = ports; return cbk(null, {listen, rest, rpc, tower}); }); }, cbk); }, // Make a private key for mining rewards miningKey: ['ecp', ({ecp}, cbk) => { const keyPair = ecp.makeRandom({network: networks.testnet}); return cbk(null, { private_key: keyPair.toWIF(), public_key: keyPair.publicKey.toString('hex'), }); }], // Spawn a backing chain daemon for lnd spawnChainDaemon: ['miningKey', ({miningKey}, cbk) => { return asyncRetry(maxSpawnChainDaemonAttempts, cbk => { return spawnChainDaemon({ daemon: 'btcd', is_tls: true, mining_public_key: miningKey.public_key, }, cbk); }, cbk); }], // Get the chain daemon cert getChainDaemonCert: ['spawnChainDaemon', ({spawnChainDaemon}, cbk) => { return asyncRetry({interval, times}, cbk => { return readFile(spawnChainDaemon.rpc_cert, (err, data) => { if (!!err) { return cbk([503, 'FailedToGetChainDaemonRpcCert', {err}]); } return cbk(null, data); }); }, cbk); }], // Generate a block to prevent lnd from getting stuck generateBlock: [ 'getChainDaemonCert', 'miningKey', 'spawnChainDaemon', ({getChainDaemonCert, miningKey, spawnChainDaemon}, cbk) => { return asyncRetry({interval, times}, cbk => { try { return generateBlocks({ cert: getChainDaemonCert, count: 1, host: localhost, key: miningKey.public_key, pass: chainPass, port: spawnChainDaemon.rpc_port, user: chainUser, }, cbk); } catch (err) { return cbk([503, 'FailedToGenerateBlockWhenSpawningLnd', {err}]); } }, cbk); }], // Spawn LND spawnLightningDaemon: [ 'generateBlock', 'getPorts', 'spawnChainDaemon', ({generateBlock, getPorts, spawnChainDaemon}, cbk) => { return asyncRetry({interval, times}, cbk => { const {dir} = spawnChainDaemon; const arguments = [ '--adminmacaroonpath', join(dir, adminMacaroonFileName), '--autopilot.heuristic', 'externalscore:0.5', '--autopilot.heuristic', 'preferential:0.5', '--bitcoin.active', '--bitcoin.chaindir', dir, '--bitcoin.minhtlc', '1000', '--bitcoin.node', 'btcd', '--bitcoin.regtest', '--datadir', dir, '--debuglevel', 'trace', '--externalip', `${localhost}:${getPorts.listen}`, '--historicalsyncinterval', '1s', '--invoicemacaroonpath', join(dir, invoiceMacaroonFileName), '--listen', `${localhost}:${getPorts.listen}`, '--logdir', join(dir, lightningDaemonLogPath), '--maxlogfilesize', 1, '--nobootstrap', '--readonlymacaroonpath', join(dir, readMacaroonFileName), '--restlisten', `${localhost}:${getPorts.rest}`, '--rpclisten', `${localhost}:${getPorts.rpc}`, '--tlscertpath', join(dir, lightningTlsCertFileName), '--tlskeypath', join(dir, lightningTlsKeyFileName), '--trickledelay', 1, '--unsafe-disconnect', ]; const btcdArgs = [ '--btcd.dir', dir, '--btcd.rpccert', join(dir, chainRpcCertName), '--btcd.rpchost', `${localhost}:${spawnChainDaemon.rpc_port}`, '--btcd.rpcpass', chainPass, '--btcd.rpcuser', chainUser, ]; btcdArgs.forEach(n => arguments.push(n)); const towerArgs = [ '--watchtower.active', '--watchtower.externalip', `${localhost}:${getPorts.tower}`, '--watchtower.listen', `${localhost}:${getPorts.tower}`, '--watchtower.towerdir', dir, ] if (!!args.circular) { arguments.push('--allow-circular-route'); } if (!!args.intercept) { arguments.push('--rpcmiddleware.enable') } if (!!args.keysend) { arguments.push('--accept-keysend'); } if (!!args.noauth) { arguments.push('--no-macaroons'); } if (!!args.tower) { towerArgs.forEach(n => arguments.push(n)); } if (!!args.watchers) { arguments.push('--wtclient.active'); } const daemon = spawn(lightningDaemonExecFileName, arguments); let isFinished = false; let isReady = false; const finished = (err, res) => { if (!!isFinished) { return; } isFinished = true; return cbk(err, res); }; daemon.stderr.on('data', data => { daemon.kill(); spawnChainDaemon.daemon.kill(); if (/unknown.flag/.test(data.toString())) { return finished(); } return finished([ 503, 'FailedToStart', `${data}`.trim().split('\n'), ]); }); daemon.stdout.on('data', data => { if (!isReady && /gRPC.proxy.started/.test(data+'')) { isReady = true; return finished(null, {daemon}); }; return; }); }, cbk); }], // Get the LND cert cert: [ 'spawnChainDaemon', 'spawnLightningDaemon', ({spawnChainDaemon, spawnLightningDaemon}, cbk) => { if (!spawnLightningDaemon) { return cbk([500, 'ExpectedLightningDaemon']); } const certPath = join(spawnChainDaemon.dir, lightningTlsCertFileName); return asyncRetry({interval, times}, cbk => { try { return cbk(null, readFileSync(certPath).toString('base64')); } catch (err) { return cbk([503, 'FailedToGetTlsCertWhenSpawningLnd', err]); } }, cbk); }], // Get connection to the no-wallet lnd nonAuthenticatedLnd: [ 'cert', 'getPorts', 'spawnLightningDaemon', ({cert, getPorts}, cbk) => { const socket = `localhost:${getPorts.rpc}`; try { return cbk(null, unauthenticatedLndGrpc({cert, socket}).lnd); } catch (err) { return cbk([503, 'FailedToInstantiateNonAuthenticatedLnd', {err}]); } }], // Wait until the wallet is active waitForActive: ['nonAuthenticatedLnd', ({nonAuthenticatedLnd}, cbk) => { const events = []; const sub = subscribeToWalletStatus({lnd: nonAuthenticatedLnd}); sub.once('absent', () => events.push('absent')); sub.once('starting', () => events.push('starting')); sub.once('active', () => { if (!events.includes('absent')) { return cbk([503, 'ExpectedWalletAbsentEvent']); } if (!events.includes('starting')) { return cbk([503, 'ExpectedWalletStartingEvent']); } events.push('active'); sub.removeAllListeners(); return cbk(); }); sub.once('error', err => { if (events.length === 3) { return; } // LND 0.12.1 and below do not support active tracking if (/unknown/.test(err.details)) { return cbk(); } return cbk(err); }); return; }], // Create seed createSeed: ['nonAuthenticatedLnd', ({nonAuthenticatedLnd}, cbk) => { // Exit early when a seed is pre-supplied if (!!args.seed) { return cbk(null, {seed: args.seed}); } return asyncRetry({interval, times}, cbk => { return createSeed({ lnd: nonAuthenticatedLnd, passphrase: lightningSeedPassphrase, }, cbk); }, cbk); }], // Create wallet createWallet: [ 'createSeed', 'nonAuthenticatedLnd', ({createSeed, nonAuthenticatedLnd}, cbk) => { return asyncRetry({interval, times}, cbk => { return createWallet({ lnd: nonAuthenticatedLnd, passphrase: lightningSeedPassphrase, password: lightningWalletPassword, seed: createSeed.seed, }, cbk); }, cbk); }], // Get admin macaroon macaroon: [ 'createWallet', 'spawnChainDaemon', ({spawnChainDaemon}, cbk) => { // Exit early when spawning an LND that has no auth if (!!args.noauth) { return cbk(); } const macaroonPath = join(spawnChainDaemon.dir, adminMacaroonFileName); return asyncRetry({interval, times}, cbk => { try { const macaroon = readFileSync(macaroonPath).toString('base64'); if (!macaroon) { throw new Error('ExpectedMacaroonDataAtMacaroonPath'); } return cbk(null, macaroon); } catch (err) { return cbk([503, 'FailedToGetAdminMacaroon', {err}]); } }, cbk); }], // Wallet details wallet: [ 'cert', 'getPorts', 'macaroon', ({cert, getPorts, macaroon}, cbk) => { return cbk(null, { cert, macaroon, socket: `localhost:${getPorts.rpc}`, }); }], // Instantiate lnd lnd: ['wallet', ({wallet}, cbk) => { try { const {lnd} = authenticatedLndGrpc({ cert: wallet.cert, macaroon: wallet.macaroon, socket: wallet.socket, }); return cbk(null, lnd); } catch (err) { return cbk([503, 'FailedToInstantiateLndWhenSpawning', {err}]); } }], // Delay to make sure everything has come together delay: ['lnd', 'waitForActive', ({lnd}, cbk) => { return asyncRetry( {interval, times}, cbk => { return getWalletInfo({lnd}, (err, res) => { if (!!err) { return cbk(err); } if (!res.is_synced_to_chain) { return cbk([503, 'ExpectedNodeSyncToChain']); } return cbk(null, res); }); }, cbk ); }], }, (err, res) => { if (!!err) { return cbk(err); } const kill = () => { res.spawnChainDaemon.daemon.kill(); res.spawnLightningDaemon.daemon.kill(); setTimeout(() => { res.spawnLightningDaemon.daemon.kill('SIGKILL'); }, 1000 * 3); return; }; const generate = ({count}) => new Promise(async (resolve, reject) => { try { return await asyncRetry({interval, times}, async () => { return resolve(await generateBlocks({ count, cert: res.getChainDaemonCert, host: localhost, key: res.miningKey.public_key, pass: chainPass, port: res.spawnChainDaemon.rpc_port, user: chainUser, })); }); } catch (err) { return reject(err); } }); process.setMaxListeners(20); process.on('uncaughtException', err => { kill(); return setTimeout(() => process.exit(1), 5000); }); return cbk(null, { generate, kill, chain_listen_port: res.spawnChainDaemon.listen_port, chain_rpc_cert: res.spawnChainDaemon.rpc_cert, chain_rpc_cert_file: res.getChainDaemonCert, chain_rpc_pass: chainPass, chain_rpc_port: res.spawnChainDaemon.rpc_port, chain_rpc_user: chainUser, listen_ip: '127.0.0.1', listen_port: res.getPorts.listen, lnd: res.lnd, lnd_cert: res.wallet.cert, lnd_macaroon: res.wallet.macaroon, lnd_socket: res.wallet.socket, mining_key: res.miningKey.private_key, public_key: res.delay.public_key, rpc_port: res.getPorts.rpc, seed: res.createSeed.seed, socket: `127.0.0.1:${res.getPorts.listen}`, }); }); };