From 56c6c591802d05caf6dc0207b421b72005a92cfa Mon Sep 17 00:00:00 2001 From: Tom Kirkpatrick Date: Tue, 31 Jul 2018 16:53:02 +0200 Subject: [PATCH] feat(lnd): support multiple lnd configurations Store lnd data within the Zap datadir and prepare to be able to support multiple lnd configurations. --- app/containers/Root.js | 1 - app/lib/lnd/config.js | 346 ++++++++++++++++----- app/lib/lnd/index.js | 18 -- app/lib/lnd/lightning.js | 6 +- app/lib/lnd/neutrino.js | 26 +- app/lib/lnd/util.js | 45 ++- app/lib/lnd/walletUnlocker.js | 18 +- app/lib/lnd/walletUnlockerMethods/index.js | 5 +- app/lib/utils/log.js | 2 +- app/lib/zap/controller.js | 111 +++++-- app/reducers/onboarding.js | 41 ++- package.json | 3 + test/unit/__mocks__/grpc.js | 1 + test/unit/lnd/lnd-config.spec.js | 208 +++++++++++++ yarn.lock | 8 +- 15 files changed, 669 insertions(+), 170 deletions(-) delete mode 100644 app/lib/lnd/index.js create mode 100644 test/unit/__mocks__/grpc.js create mode 100644 test/unit/lnd/lnd-config.spec.js diff --git a/app/containers/Root.js b/app/containers/Root.js index c03fd1c4..92591d8d 100644 --- a/app/containers/Root.js +++ b/app/containers/Root.js @@ -1,4 +1,3 @@ -// @flow import React from 'react' import { Provider, connect } from 'react-redux' import { ConnectedRouter } from 'react-router-redux' diff --git a/app/lib/lnd/config.js b/app/lib/lnd/config.js index b24204bd..bfd0b4e5 100644 --- a/app/lib/lnd/config.js +++ b/app/lib/lnd/config.js @@ -1,92 +1,292 @@ -import { homedir, platform } from 'os' -import { dirname, join, normalize } from 'path' -import Store from 'electron-store' +// @flow + +import { join } from 'path' import { app } from 'electron' -import isDev from 'electron-is-dev' +import Store from 'electron-store' +import pick from 'lodash.pick' +import createDebug from 'debug' import untildify from 'untildify' +import tildify from 'tildify' +import { appRootPath, binaryPath } from './util' + +const debug = createDebug('zap:lnd-config') + +// Supported connection types. +const types = { + local: 'Local', + custom: 'Custom', + btcpayserver: 'BTCPay Server' +} -// Get a path to prepend to any nodejs calls that are getting at files in the package, -// so that it works both from source and in an asar-packaged mac app. -// See https://github.com/electron-userland/electron-builder/issues/751 -// -// windows from source: "C:\myapp\node_modules\electron\dist\resources\default_app.asar" -// mac from source: "/Users/me/dev/myapp/node_modules/electron/dist/Electron.app/Contents/Resources/default_app.asar" -// mac from a package: "/my.app/Contents/Resources/app.asar" -// -// If we are run from outside of a packaged app, our working directory is the right place to be. -// And no, we can't just set our working directory to somewhere inside the asar. The OS can't handle that. -const appPath = app.getAppPath() -const appRootPath = appPath.indexOf('default_app.asar') < 0 ? normalize(`${appPath}/..`) : '' - -// Get the name of the current platform which we can use to determine the tlsCertPathation of various lnd resources. -const plat = platform() - -// Get the OS specific default lnd data dir and binary name. -let lndDataDir -let lndBin -switch (plat) { - case 'darwin': - lndDataDir = join(homedir(), 'Library', 'Application Support', 'Lnd') - lndBin = 'lnd' - break - case 'linux': - lndDataDir = join(homedir(), '.lnd') - lndBin = 'lnd' - break - case 'win32': - lndDataDir = join(process.env.LOCALAPPDATA, 'Local', 'Lnd') - lndBin = 'lnd.exe' - break - default: - break +// Supported currencies. +const currencties = { + bitcoin: 'Bitcoin', + litecoin: 'Litecoin' } -// Get the path to the lnd binary. -let lndPath -if (isDev) { - lndPath = join(dirname(require.resolve('lnd-binary/package.json')), 'vendor', lndBin) -} else { - lndPath = join(appRootPath, 'bin', lndBin) +// Supported networks. +const networks = { + mainnet: 'Mainnet', + testnet: 'Testnet' } +// Type definition for for local connection settings. +type LndConfigSettingsLocalType = {| + alias?: string, + autopilot?: boolean +|} + +// Type definition for for custom connection settings. +type LndConfigSettingsCustomType = {| + host: string, + cert: string, + macaroon: string +|} + +// Type definition for for BTCPay Server connection settings. +type LndConfigSettingsBtcPayServerType = {| + string: string, + host: string, + macaroon: string +|} + +// Type definition for for BTCPay Server connection settings. +type LndConfigSettingsType = {| + type: $Keys, + currency: $Keys, + network: $Keys, + wallet: string +|} + +// Type definition for LndConfig constructor options. +type LndConfigOptions = {| + ...LndConfigSettingsType, + settings?: + | LndConfigSettingsLocalType + | LndConfigSettingsCustomType + | LndConfigSettingsBtcPayServerType +|} + +const _host = new WeakMap() +const _cert = new WeakMap() +const _macaroon = new WeakMap() +const _string = new WeakMap() + +/** + * Utility methods to clean and prepare data. + */ +const safeTrim = (val: ?T): ?T => (typeof val === 'string' ? val.trim() : val) +const safeTildify = (val: ?T): ?T => (typeof val === 'string' ? tildify(val) : val) +const safeUntildify = (val: ?T): ?T => (typeof val === 'string' ? untildify(val) : val) + /** - * Get current lnd configuration. - * - * Cert and Macaroon will be at one of the following destinations depending on your machine: - * Mac OS X: ~/Library/Application Support/Lnd/tls.cert - * Linux: ~/.lnd/tls.cert - * Windows: C:\Users\...\AppData\Local\Lnd - * - * @return {object} current lnd configuration options. + * LndConfig class */ -const lnd = () => { - // Get an electron store named 'connection' in which the saved connection detailes are stored. - const store = new Store({ name: 'connection' }) +class LndConfig { + static DEFAULT_CONFIG = { + type: 'local', + currency: 'bitcoin', + network: 'testnet', + wallet: 'wallet-1' + } + static SETTINGS_PROPS = { + local: ['alias', 'autopilot'], + custom: ['host', 'cert', 'macaroon'], + btcpayserver: ['host', 'macaroon', 'string'] + } + static store = new Store({ name: 'connection' }) + + // Type descriptor properties. + type: string + currency: string + network: string + wallet: string + + // User configurable settings. + host: ?string + cert: ?string + macaroon: ?string + string: ?string + alias: ?string + autopilot: ?boolean + + // Read only data properties. + +key: string + +binaryPath: string + +dataDir: string + +configPath: string + +rpcProtoPath: string /** - * Fetch a config option from the connection store. - * if undefined fallback to a path relative to the lnd data dir. + * Lnd configuration class. * - * @param {string} name name of property to fetch from the store. - * @param {string} path path relative to the lnd data dir. - * @return {string} config param or filepath relative to the lnd data dir. + * @param {LndConfigOptions} [options] Lnd config options. + * @param {string} options.type config type (Local|custom|btcpayserver) + * @param {string} options.currency config currency (bitcoin|litecoin) + * @param {string} options.network config network (mainnet|testnet) + * @param {string} options.wallet config wallet name (eg wallet-1) + * @param {Object} [options.settings] config settings used to initialise the config with. */ - const getFromStoreOrDataDir = (name, file) => { - let path = store.get(name) - if (typeof path === 'undefined') { - path = join(lndDataDir, file) + constructor(options?: LndConfigOptions) { + debug('Constructor called with options: %o', options) + + // Define properties that we support with custom getters and setters as needed. + // flow currently doesn't support defineProperties properly (https://github.com/facebook/flow/issues/285) + const { defineProperties } = Object + defineProperties(this, { + key: { + get() { + return `${this.type}.${this.currency}.${this.network}.${this.wallet}` + } + }, + binaryPath: { + enumerable: true, + value: binaryPath + }, + dataDir: { + enumerable: true, + get() { + return join(app.getPath('userData'), 'lnd', this.currency, this.network, this.wallet) + } + }, + configPath: { + enumerable: true, + get() { + return join(appRootPath, 'resources', 'lnd.conf') + } + }, + rpcProtoPath: { + enumerable: true, + get() { + return join(appRootPath, 'resources', 'rpc.proto') + } + }, + + // Getters / Setters for host property. + // - Trim value before saving. + host: { + enumerable: true, + get() { + return _host.get(this) + }, + set(value: string) { + _host.set(this, safeTrim(value)) + } + }, + + // Getters / Setters for cert property. + // - Untildify value on retrieval. + // - Trim value before saving. + cert: { + enumerable: true, + get() { + return safeUntildify(_cert.get(this)) + }, + set(value: string) { + _cert.set(this, safeTrim(value)) + } + }, + + // Getters / Setters for macaroon property. + // - Untildify value on retrieval. + // - Trim value before saving. + macaroon: { + enumerable: true, + get() { + return safeUntildify(_macaroon.get(this)) + }, + set(value: string) { + _macaroon.set(this, safeTrim(value)) + } + }, + + // Getters / Setters for string property. + // - Trim value before saving. + string: { + enumerable: true, + get() { + return _string.get(this) + }, + set(value: string) { + _string.set(this, safeTrim(value)) + } + } + }) + + // If options were provided, use them to initialise the instance. + if (options) { + this.type = options.type + this.currency = options.currency + this.network = options.network + this.wallet = options.wallet + + // If settings were provided then clean them up and assign them to the instance for easy access. + if (options.settings) { + debug('Setting settings as: %o', options.settings) + Object.assign(this, options.settings) + } + } + + // If no options were provided load the details of the current active or default wallet. + else { + const settings = new Store({ name: 'settings' }) + const activeConnection: ?LndConfigSettingsType = settings.get('activeConnection') + debug('Determined active connection as: %o', activeConnection) + + if (activeConnection && Object.keys(activeConnection).length > 0) { + debug('Assigning connection details from activeConnection as: %o', activeConnection) + Object.assign(this, activeConnection) + } + + // If the connection settings were not found for the configured active connection, load the default values. + debug('Fetching connection config for %s', this.key) + if (!this.key || (this.key && !LndConfig.store.has(this.key))) { + debug('Active connection config not found. Setting config as: %o', LndConfig.DEFAULT_CONFIG) + Object.assign(this, LndConfig.DEFAULT_CONFIG) + } + } + + // For local configs host/cert/macaroon are auto-generated. + if (this.type === 'local') { + const defaultLocalOptions = { + host: 'localhost:10009', + cert: join(this.dataDir, 'tls.cert'), + macaroon: join(this.dataDir, 'admin.macaroon') + } + debug('Connection type is local. Assigning settings as: %o', defaultLocalOptions) + Object.assign(this, defaultLocalOptions) } - return untildify(path) } - return { - lndPath, - configPath: join(appRootPath, 'resources', 'lnd.conf'), - rpcProtoPath: join(appRootPath, 'resources', 'rpc.proto'), - host: store.get('host', 'localhost:10009'), - cert: getFromStoreOrDataDir('cert', 'tls.cert'), - macaroon: getFromStoreOrDataDir('macaroon', 'admin.macaroon') + /** + * Load settings for this configuration from the store. + * @return {LndConfig} Updated LndConfig object. + */ + load() { + const settings = pick(LndConfig.store.get(this.key, {}), LndConfig.SETTINGS_PROPS[this.type]) + debug('Loaded settings for %s config as: %o', this.key, settings) + return Object.assign(this, settings) + } + + /** + * Save settings for this configuration to the store. + * @return {LndConfig} Updated LndConfig object. + */ + save() { + const settings = pick(this, LndConfig.SETTINGS_PROPS[this.type]) + + // Tildify cert and macaroon values before storing for better portability. + if (settings.cert) { + settings.cert = safeTildify(settings.cert) + } + if (settings.macaroon) { + settings.macaroon = safeTildify(settings.macaroon) + } + + debug('Saving settings for %s config as: %o', this.key, settings) + LndConfig.store.set(this.key, settings) + return this } } -export default { lnd } +export default LndConfig diff --git a/app/lib/lnd/index.js b/app/lib/lnd/index.js deleted file mode 100644 index 25bae332..00000000 --- a/app/lib/lnd/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import config from './config' -import walletUnlocker from './walletUnlocker' -import walletUnlockerMethods from './walletUnlockerMethods' -// use mainLog because lndLog is reserved for the lnd binary itself -import { mainLog } from '../utils/log' - -const initWalletUnlocker = () => { - const lndConfig = config.lnd() - const walletUnlockerObj = walletUnlocker(lndConfig.rpcProtoPath, lndConfig.host) - const walletUnlockerMethodsCallback = (event, msg, data) => - walletUnlockerMethods(walletUnlockerObj, mainLog, event, msg, data) - - return walletUnlockerMethodsCallback -} - -export default { - initWalletUnlocker -} diff --git a/app/lib/lnd/lightning.js b/app/lib/lnd/lightning.js index a0e835a8..67a2a75b 100644 --- a/app/lib/lnd/lightning.js +++ b/app/lib/lnd/lightning.js @@ -1,6 +1,5 @@ import grpc from 'grpc' import { loadSync } from '@grpc/proto-loader' -import config from './config' import { getDeadline, validateHost, createSslCreds, createMacaroonCreds } from './util' import methods from './methods' import { mainLog } from '../utils/log' @@ -27,9 +26,8 @@ class Lightning { * Connect to the gRPC interface and verify it is functional. * @return {Promise} */ - async connect() { - const lndConfig = config.lnd() - const { host, rpcProtoPath, cert, macaroon } = lndConfig + async connect(lndConfig) { + const { rpcProtoPath, host, cert, macaroon } = lndConfig // Verify that the host is valid before creating a gRPC client that is connected to it. return await validateHost(host).then(async () => { diff --git a/app/lib/lnd/neutrino.js b/app/lib/lnd/neutrino.js index a878d262..423eda4b 100644 --- a/app/lib/lnd/neutrino.js +++ b/app/lib/lnd/neutrino.js @@ -1,7 +1,6 @@ import split2 from 'split2' import { spawn } from 'child_process' import EventEmitter from 'events' -import config from './config' import { mainLog, lndLog, lndLogGetLevel } from '../utils/log' import { fetchBlockHeight } from './util' @@ -24,10 +23,9 @@ const GOT_LND_BLOCK_HEIGHT = 'got-lnd-block-height' * @extends EventEmitter */ class Neutrino extends EventEmitter { - constructor(alias, autopilot) { + constructor(lndConfig) { super() - this.alias = alias - this.autopilot = autopilot + this.lndConfig = lndConfig this.process = null this.walletUnlockerGrpcActive = false this.lightningGrpcActive = false @@ -43,21 +41,21 @@ class Neutrino extends EventEmitter { throw new Error('Neutrino process with PID ${this.process.pid} already exists.') } - const lndConfig = config.lnd() mainLog.info('Starting lnd in neutrino mode') - mainLog.debug(' > lndPath', lndConfig.lndPath) - mainLog.debug(' > rpcProtoPath:', lndConfig.rpcProtoPath) - mainLog.debug(' > host:', lndConfig.host) - mainLog.debug(' > cert:', lndConfig.cert) - mainLog.debug(' > macaroon:', lndConfig.macaroon) + mainLog.info(' > binaryPath', this.lndConfig.binaryPath) + mainLog.info(' > rpcProtoPath:', this.lndConfig.rpcProtoPath) + mainLog.info(' > host:', this.lndConfig.host) + mainLog.info(' > cert:', this.lndConfig.cert) + mainLog.info(' > macaroon:', this.lndConfig.macaroon) const neutrinoArgs = [ - `--configfile=${lndConfig.configPath}`, - `${this.autopilot ? '--autopilot.active' : ''}`, - `${this.alias ? `--alias=${this.alias}` : ''}` + `--configfile=${this.lndConfig.configPath}`, + `--lnddir=${this.lndConfig.dataDir}`, + `${this.lndConfig.autopilot ? '--autopilot.active' : ''}`, + `${this.lndConfig.alias ? `--alias=${this.lndConfig.alias}` : ''}` ] - this.process = spawn(lndConfig.lndPath, neutrinoArgs) + this.process = spawn(this.lndConfig.binaryPath, neutrinoArgs) .on('error', error => this.emit(ERROR, error)) .on('close', code => { this.emit(CLOSE, code) diff --git a/app/lib/lnd/util.js b/app/lib/lnd/util.js index 1f21b75f..0656abdd 100644 --- a/app/lib/lnd/util.js +++ b/app/lib/lnd/util.js @@ -2,8 +2,11 @@ import dns from 'dns' import fs from 'fs' import axios from 'axios' import { promisify } from 'util' +import { basename, dirname, join, normalize } from 'path' +import { platform } from 'os' import { lookup } from 'ps-node' -import path from 'path' +import { app } from 'electron' +import isDev from 'electron-is-dev' import grpc from 'grpc' import isIP from 'validator/lib/isIP' import isPort from 'validator/lib/isPort' @@ -13,6 +16,44 @@ import { mainLog } from '../utils/log' const fsReadFile = promisify(fs.readFile) const dnsLookup = promisify(dns.lookup) +// ------------------------------------ +// Constants +// ------------------------------------ + +/** + * Get a path to prepend to any nodejs calls that are getting at files in the package, + * so that it works both from source and in an asar-packaged mac app. + * See https://github.com/electron-userland/electron-builder/issues/751 + * + * windows from source: "C:\myapp\node_modules\electron\dist\resources\default_app.asar" + * mac from source: "/Users/me/dev/myapp/node_modules/electron/dist/Electron.app/Contents/Resources/default_app.asar" + * mac from a package: "/my.app/Contents/Resources/app.asar" + * + * If we are run from outside of a packaged app, our working directory is the right place to be. + * And no, we can't just set our working directory to somewhere inside the asar. The OS can't handle that. + * @return {String} Path to the lnd binary. + */ +export const appRootPath = + app.getAppPath().indexOf('default_app.asar') < 0 ? normalize(`${app.getAppPath()}/..`) : '' + +/** + * Get the OS specific lnd binary name. + * @return {String} 'lnd' on mac or linux, 'lnd.exe' on windows. + */ +export const binaryName = platform() === 'win32' ? 'lnd.exe' : 'lnd' + +/** + * Get the OS specific path to the lnd binary. + * @return {String} Path to the lnd binary. + */ +export const binaryPath = isDev + ? join(dirname(require.resolve('lnd-binary/package.json')), 'vendor', binaryName) + : join(appRootPath, 'bin', binaryName) + +// ------------------------------------ +// Helpers +// ------------------------------------ + /** * Helper function to get the current block height. * @return {Number} The current block height. @@ -123,7 +164,7 @@ export const createMacaroonCreds = async macaroonPath => { const metadata = new grpc.Metadata() // If it's not a filepath, then assume it is a hex encoded string. - if (macaroonPath === path.basename(macaroonPath)) { + if (macaroonPath === basename(macaroonPath)) { metadata.add('macaroon', macaroonPath) } else { const macaroon = await fsReadFile(macaroonPath).catch(e => { diff --git a/app/lib/lnd/walletUnlocker.js b/app/lib/lnd/walletUnlocker.js index ae2ae372..537f3250 100644 --- a/app/lib/lnd/walletUnlocker.js +++ b/app/lib/lnd/walletUnlocker.js @@ -1,10 +1,18 @@ import fs from 'fs' import grpc from 'grpc' import { loadSync } from '@grpc/proto-loader' -import config from './config' +import walletUnlockerMethods from './walletUnlockerMethods' +import { mainLog } from '../utils/log' -const walletUnlocker = (rpcpath, host) => { - const lndConfig = config.lnd() +export const initWalletUnlocker = lndConfig => { + const walletUnlockerObj = walletUnlocker(lndConfig) + const walletUnlockerMethodsCallback = (event, msg, data) => + walletUnlockerMethods(lndConfig, walletUnlockerObj, mainLog, event, msg, data) + + return walletUnlockerMethodsCallback +} + +export const walletUnlocker = lndConfig => { const lndCert = fs.readFileSync(lndConfig.cert) const credentials = grpc.credentials.createSsl(lndCert) @@ -24,7 +32,5 @@ const walletUnlocker = (rpcpath, host) => { const rpc = grpc.loadPackageDefinition(packageDefinition) // Instantiate a new connection to the WalletUnlocker interface. - return new rpc.lnrpc.WalletUnlocker(host, credentials) + return new rpc.lnrpc.WalletUnlocker(lndConfig.host, credentials) } - -export default walletUnlocker diff --git a/app/lib/lnd/walletUnlockerMethods/index.js b/app/lib/lnd/walletUnlockerMethods/index.js index de36f493..094f79f8 100644 --- a/app/lib/lnd/walletUnlockerMethods/index.js +++ b/app/lib/lnd/walletUnlockerMethods/index.js @@ -1,10 +1,7 @@ import { dirname } from 'path' import * as walletController from '../methods/walletController' -import config from '../config' - -export default function(walletUnlocker, log, event, msg, data) { - const lndConfig = config.lnd() +export default function(lndConfig, walletUnlocker, log, event, msg, data) { const decorateError = error => { switch (error.code) { // wallet already exists diff --git a/app/lib/utils/log.js b/app/lib/utils/log.js index 80676dac..f40cb97a 100644 --- a/app/lib/utils/log.js +++ b/app/lib/utils/log.js @@ -8,7 +8,7 @@ debugLogger.inspectOptions = { // Enable all zap logs if DEBUG has not been explicitly set. if (!process.env.DEBUG) { // debugLogger.debug.enable('zap:*') - process.env.DEBUG = 'zap:*' + process.env.DEBUG = 'zap:main,zap:lnd,zap:updater' } if (!process.env.DEBUG_LEVEL) { process.env.DEBUG_LEVEL = 'info' diff --git a/app/lib/zap/controller.js b/app/lib/zap/controller.js index 638a73d3..018b6e45 100644 --- a/app/lib/zap/controller.js +++ b/app/lib/zap/controller.js @@ -1,12 +1,25 @@ -import { app, ipcMain, dialog } from 'electron' +// @flow + +import { app, ipcMain, dialog, BrowserWindow } from 'electron' +import pick from 'lodash.pick' import Store from 'electron-store' import StateMachine from 'javascript-state-machine' -import lnd from '../lnd' +import LndConfig from '../lnd/config' import Neutrino from '../lnd/neutrino' import Lightning from '../lnd/lightning' +import { initWalletUnlocker } from '../lnd/walletUnlocker' import { mainLog } from '../utils/log' import { isLndRunning } from '../lnd/util' +type onboardingOptions = { + type: 'local' | 'custom' | 'btcpayserver', + host?: string, + cert?: string, + macaroon?: string, + alias?: string, + autopilot?: boolean +} + const grpcSslCipherSuites = connectionType => (connectionType === 'btcpayserver' ? [ @@ -37,11 +50,26 @@ const grpcSslCipherSuites = connectionType => * The ZapController class coordinates actions between the the main nand renderer processes. */ class ZapController { + mainWindow: BrowserWindow + neutrino: Neutrino + lightning: Lightning + splashScreenTime: number + lightningGrpcConnected: boolean + lndConfig: LndConfig + _fsm: StateMachine + + // Transitions provided by the state machine. + startOnboarding: any + startLnd: any + connectLnd: any + terminate: any + is: any + /** * Create a new ZapController instance. * @param {BrowserWindow} mainWindow BrowserWindow instance to interact with. */ - constructor(mainWindow) { + constructor(mainWindow: BrowserWindow) { // Variable to hold the main window instance. this.mainWindow = mainWindow @@ -59,6 +87,10 @@ class ZapController { // Initialize the state machine. this._fsm() + + // Initialise the controler with the current active config. + this.lndConfig = new LndConfig() + this.lndConfig.load() } /** @@ -101,11 +133,12 @@ class ZapController { onStartOnboarding() { mainLog.debug('[FSM] onStartOnboarding...') - this.sendMessage('startOnboarding') + this.sendMessage('startOnboarding', this.lndConfig) } - onStartLnd(options) { + onStartLnd() { mainLog.debug('[FSM] onStartLnd...') + return isLndRunning().then(res => { if (res) { mainLog.error('lnd already running: %s', res) @@ -115,20 +148,23 @@ class ZapController { }) return app.quit() } + mainLog.info('Starting new lnd instance') - mainLog.info(' > alias:', options.alias) - mainLog.info(' > autopilot:', options.autopilot) - return this.startNeutrino(options.alias, options.autopilot) + mainLog.info(' > alias:', this.lndConfig.alias) + mainLog.info(' > autopilot:', this.lndConfig.autopilot) + + return this.startNeutrino() }) } - onConnectLnd(options) { + onConnectLnd() { mainLog.debug('[FSM] onConnectLnd...') mainLog.info('Connecting to custom lnd instance') - mainLog.info(' > host:', options.host) - mainLog.info(' > cert:', options.cert) - mainLog.info(' > macaroon:', options.macaroon) - this.startLightningWallet() + mainLog.info(' > host:', this.lndConfig.host) + mainLog.info(' > cert:', this.lndConfig.cert) + mainLog.info(' > macaroon:', this.lndConfig.macaroon) + + return this.startLightningWallet() .then(() => this.sendMessage('finishOnboarding')) .catch(e => { const errors = {} @@ -183,7 +219,7 @@ class ZapController { * @param {string} msg message to send. * @param {[type]} data additional data to acompany the message. */ - sendMessage(msg, data) { + sendMessage(msg: string, data: any) { if (this.mainWindow) { mainLog.info('Sending message to renderer process: %o', { msg, data }) this.mainWindow.webContents.send(msg, data) @@ -201,7 +237,7 @@ class ZapController { startWalletUnlocker() { mainLog.info('Starting wallet unlocker...') try { - const walletUnlockerMethods = lnd.initWalletUnlocker() + const walletUnlockerMethods = initWalletUnlocker(this.lndConfig) // Listen for all gRPC restful methods ipcMain.on('walletUnlocker', (event, { msg, data }) => { @@ -227,7 +263,7 @@ class ZapController { this.lightning = new Lightning() // Connect to the Lightning interface. - await this.lightning.connect() + await this.lightning.connect(this.lndConfig) // Subscribe the main window to receive streams. this.lightning.subscribe(this.mainWindow) @@ -265,9 +301,9 @@ class ZapController { * @param {boolean} autopilot True if autopilot should be enabled. * @return {Neutrino} Neutrino instance. */ - startNeutrino(alias, autopilot) { + startNeutrino() { mainLog.info('Starting Neutrino...') - this.neutrino = new Neutrino(alias, autopilot) + this.neutrino = new Neutrino(this.lndConfig) this.neutrino.on('error', error => { mainLog.error(`Got error from lnd process: ${error})`) @@ -279,7 +315,7 @@ class ZapController { this.neutrino.on('close', code => { mainLog.info(`Lnd process has shut down (code ${code})`) - if (['running', 'connected'].includes(this.state)) { + if (this.is('running') || this.is('connected')) { dialog.showMessageBox({ type: 'error', message: `Lnd has unexpectadly quit` @@ -324,18 +360,27 @@ class ZapController { this.neutrino.start() } - finishOnboarding(options) { - // Trim any user supplied strings. - const cleanOptions = Object.keys(options).reduce((previous, current) => { - previous[current] = - typeof options[current] === 'string' ? options[current].trim() : options[current] - return previous - }, {}) - - // Save the options. - const store = new Store({ name: 'connection' }) - store.store = cleanOptions - mainLog.info('Saved lnd config to %s: %o', store.path, store.store) + finishOnboarding(options: onboardingOptions) { + mainLog.info('Finishing onboarding') + // Save the lnd config options that we got from the renderer. + this.lndConfig = new LndConfig({ + type: options.type, + currency: 'bitcoin', + network: 'testnet', + wallet: 'wallet-1', + settings: pick(options, LndConfig.SETTINGS_PROPS[options.type]) + }) + this.lndConfig.save() + + // Set as the active config. + const settings = new Store({ name: 'settings' }) + settings.set('activeConnection', { + type: this.lndConfig.type, + currency: this.lndConfig.currency, + network: this.lndConfig.network, + wallet: this.lndConfig.wallet + }) + mainLog.info('Saved active connection as: %o', settings.get('activeConnection')) // Set up SSL with the cypher suits that we need based on the connection type. process.env.GRPC_SSL_CIPHER_SUITES = @@ -343,14 +388,14 @@ class ZapController { // If the requested connection type is a local one then start up a new lnd instance. // // Otherwise attempt to connect to an lnd instance using user supplied connection details. - cleanOptions.type === 'local' ? this.startLnd() : this.connectLnd() + return options.type === 'local' ? this.startLnd() : this.connectLnd() } /** * Add IPC event listeners... */ _registerIpcListeners() { - ipcMain.on('startLnd', (event, options = {}) => this.finishOnboarding(options)) + ipcMain.on('startLnd', (event, options: onboardingOptions) => this.finishOnboarding(options)) } } diff --git a/app/reducers/onboarding.js b/app/reducers/onboarding.js index 341ab327..7bd4e754 100644 --- a/app/reducers/onboarding.js +++ b/app/reducers/onboarding.js @@ -1,10 +1,7 @@ import { createSelector } from 'reselect' import { ipcRenderer } from 'electron' -import Store from 'electron-store' import get from 'lodash.get' -const store = new Store({ name: 'connection' }) - // ------------------------------------ // Constants // ------------------------------------ @@ -230,7 +227,25 @@ export const submitNewWallet = ( dispatch({ type: CREATING_NEW_WALLET }) } -export const startOnboarding = () => dispatch => { +// Listener for errors connecting to LND gRPC +export const startOnboarding = (event, lndConfig = {}) => dispatch => { + dispatch(setConnectionType(lndConfig.type)) + + switch (lndConfig.type) { + case 'local': + dispatch(updateAlias(lndConfig.alias)) + dispatch(setAutopilot(lndConfig.autopilot)) + break + case 'custom': + dispatch(setConnectionHost(lndConfig.host)) + dispatch(setConnectionCert(lndConfig.cert)) + dispatch(setConnectionMacaroon(lndConfig.macaroon)) + break + case 'btcpayserver': + dispatch(setConnectionString(lndConfig.string)) + break + } + dispatch({ type: ONBOARDING_STARTED }) } @@ -447,16 +462,18 @@ export { onboardingSelectors } // ------------------------------------ // Reducer // ------------------------------------ + const initialState = { onboarding: false, onboarded: false, step: 0.1, - connectionType: store.get('type', 'local'), - connectionString: store.get('string', ''), - connectionHost: store.get('host', ''), - connectionCert: store.get('cert', ''), - connectionMacaroon: store.get('macaroon', ''), - alias: store.get('alias', ''), + connectionType: 'default', + connectionString: '', + connectionHost: '', + connectionCert: '', + connectionMacaroon: '', + alias: '', + autopilot: true, password: '', startingLnd: false, @@ -493,9 +510,7 @@ const initialState = { signupForm: { create: true, import: false - }, - - autopilot: store.get('autopilot', true) + } } // ------------------------------------ diff --git a/package.json b/package.json index 5946ea6c..71ca16cc 100644 --- a/package.json +++ b/package.json @@ -280,6 +280,7 @@ "axios": "^0.16.2", "bitcoinjs-lib": "^3.2.0", "copy-to-clipboard": "^3.0.8", + "debug": "^3.1.0", "debug-logger": "^0.4.1", "devtron": "^1.4.0", "electron": "^2.0.5", @@ -290,6 +291,7 @@ "history": "^4.7.2", "javascript-state-machine": "^3.1.0", "lodash.get": "^4.4.2", + "lodash.pick": "^4.4.0", "moment": "^2.22.2", "prop-types": "^15.6.2", "ps-node": "^0.1.6", @@ -311,6 +313,7 @@ "satoshi-bitcoin": "^1.0.4", "source-map-support": "^0.5.6", "split2": "^2.2.0", + "tildify": "^1.2.0", "untildify": "^3.0.3", "validator": "^10.4.0" }, diff --git a/test/unit/__mocks__/grpc.js b/test/unit/__mocks__/grpc.js new file mode 100644 index 00000000..4ba52ba2 --- /dev/null +++ b/test/unit/__mocks__/grpc.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/unit/lnd/lnd-config.spec.js b/test/unit/lnd/lnd-config.spec.js new file mode 100644 index 00000000..80ca746a --- /dev/null +++ b/test/unit/lnd/lnd-config.spec.js @@ -0,0 +1,208 @@ +// @flow + +import { join, normalize } from 'path' +import Store from 'electron-store' +import LndConfig from 'lib/lnd/config' + +jest.mock('grpc') + +jest.mock('electron', () => { + const { normalize } = require('path') + + return { + app: { + getPath: name => normalize(`/tmp/zap-test/${name}`), + getAppPath: () => normalize('/tmp/zap-test') + } + } +}) + +jest.mock('lib/lnd/util', () => { + const { normalize } = require('path') + + return { + ...jest.requireActual('lib/lnd/util'), + appRootPath: normalize('/tmp/zap-test/app/root'), + binaryName: 'binaryName', + binaryPath: 'binaryPath' + } +}) + +Store.prototype.set = jest.fn() + +describe('LndConfig', function() { + const checkForStaticProperties = () => { + it('should have "binaryPath" set to the value returned by lib/lnd/util', () => { + expect(this.lndConfig.binaryPath).toEqual('binaryPath') + }) + it('should have "configPath" set to "resources/lnd.conf" relative to app root from lib/lnd/util"', () => { + expect(this.lndConfig.configPath).toEqual( + normalize('/tmp/zap-test/app/root/resources/lnd.conf') + ) + }) + it('should have "rpcProtoPath" set to "resources/rcp.proto" relative to app root from lib/lnd/util"', () => { + expect(this.lndConfig.rpcProtoPath).toEqual( + normalize('/tmp/zap-test/app/root/resources/rpc.proto') + ) + }) + } + + const checkForConfigProperties = type => { + it(`should have the "type" property set to the ${type} value`, () => { + expect(this.lndConfig.type).toEqual(this.type) + }) + it(`should have the "currency" property set to the ${type} value`, () => { + expect(this.lndConfig.currency).toEqual(this.currency) + }) + it(`should have the "network" property set to the ${type}`, () => { + expect(this.lndConfig.network).toEqual(this.network) + }) + it(`should have the "wallet" property set to the ${type}`, () => { + expect(this.lndConfig.wallet).toEqual(this.wallet) + }) + it(`should have the "dataDir" set to a path derived from the config, under the app userData dir`, () => { + const baseDir = '/tmp/zap-test/userData/lnd/' + const expectedDataDir = join(baseDir, this.currency, this.network, this.wallet) + expect(this.lndConfig.dataDir).toEqual(expectedDataDir) + }) + } + + const checkForLoadedProperties = () => { + it(`should have the "host" property set to the default value`, () => { + expect(this.lndConfig.host).toEqual(this.host) + }) + it('should have the "cert" property set to a path relative to the datadir', () => { + expect(this.lndConfig.cert).toEqual(this.cert) + }) + it('should have the "macaroon" property set to a path relative to the datadir', () => { + expect(this.lndConfig.macaroon).toEqual(this.macaroon) + }) + } + + const checkForSaveBehaviour = expectedData => { + it('should save the config to a file', () => { + expect(Store.prototype.set).toHaveBeenCalledWith( + `${this.type}.${this.currency}.${this.network}.${this.wallet}`, + expectedData + ) + }) + } + + describe('"local" type', () => { + describe('New config with default options', () => { + beforeAll(() => { + this.type = 'local' + this.currency = 'bitcoin' + this.network = 'testnet' + this.wallet = 'wallet-1' + + this.lndConfig = new LndConfig() + + this.host = 'localhost:10009' + this.cert = join(this.lndConfig.dataDir, 'tls.cert') + this.macaroon = join(this.lndConfig.dataDir, 'admin.macaroon') + }) + + describe('static properties', () => { + checkForStaticProperties() + }) + describe('config properties', () => { + checkForConfigProperties('default') + }) + describe('.load()', () => { + beforeAll(() => this.lndConfig.load()) + checkForLoadedProperties() + }) + describe('.save() - no settings', () => { + beforeAll(() => this.lndConfig.save()) + checkForSaveBehaviour({}) + }) + describe('.save() - with settings', () => { + beforeAll(() => { + this.lndConfig.alias = 'some-alias1' + this.lndConfig.autopilot = true + this.lndConfig.save() + }) + checkForSaveBehaviour({ alias: 'some-alias1', autopilot: true }) + }) + }) + + describe('New config with provided options', () => { + beforeAll(() => { + this.type = 'local' + this.currency = 'litecoin' + this.network = 'mainnet' + this.wallet = 'wallet-2' + + this.lndConfig = new LndConfig({ + type: this.type, + currency: this.currency, + network: this.network, + wallet: this.wallet + }) + + this.host = 'localhost:10009' + this.cert = join(this.lndConfig.dataDir, 'tls.cert') + this.macaroon = join(this.lndConfig.dataDir, 'admin.macaroon') + }) + + describe('static properties', () => { + checkForStaticProperties() + }) + describe('config properties', () => { + checkForConfigProperties('provided') + }) + describe('.load()', () => { + beforeAll(() => this.lndConfig.load()) + checkForLoadedProperties() + }) + describe('.save() - no settings', () => { + beforeAll(() => this.lndConfig.save()) + checkForSaveBehaviour({}) + }) + describe('.save() - with settings', () => { + beforeAll(() => { + this.lndConfig.alias = 'some-alias2' + this.lndConfig.autopilot = true + this.lndConfig.save() + }) + checkForSaveBehaviour({ alias: 'some-alias2', autopilot: true }) + }) + }) + + describe('New config with provided options and initial configuration', () => { + beforeAll(() => { + this.type = 'custom' + this.currency = 'bitcoin' + this.network = 'testnet' + this.wallet = 'wallet-1' + this.host = 'some-host' + this.cert = 'some-cert' + this.macaroon = 'some-macaroon' + + this.lndConfig = new LndConfig({ + type: this.type, + currency: this.currency, + network: this.network, + wallet: this.wallet, + settings: { + host: this.host, + cert: this.cert, + macaroon: this.macaroon + } + }) + }) + + describe('static properties', () => { + checkForStaticProperties() + }) + describe('config properties', () => { + checkForConfigProperties('provided') + }) + describe('.save()', () => { + beforeAll(() => this.lndConfig.save()) + checkForSaveBehaviour({ host: 'some-host', cert: 'some-cert', macaroon: 'some-macaroon' }) + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 96c8d013..f40ba24d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7582,7 +7582,7 @@ lodash.omit@4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" -lodash.pick@4.4.0: +lodash.pick@4.4.0, lodash.pick@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" @@ -11650,6 +11650,12 @@ through@2, "through@>=2.2.7 <3", through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" +tildify@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a" + dependencies: + os-homedir "^1.0.0" + time-fix-plugin@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/time-fix-plugin/-/time-fix-plugin-2.0.3.tgz#b6b1ead519099bc621e28edb77dac7531918b7e1"