├── README.md ├── src ├── App.js └── action │ ├── ipc-mobile.js │ ├── keychain-mobile.js │ ├── ipc.js │ ├── app-storage.js │ ├── setting.js │ ├── log.js │ ├── notification.js │ ├── file-mobile.js │ ├── info.js │ ├── nav.js │ ├── autopilot.js │ ├── invoice.js │ ├── index.js │ ├── backup-mobile.js │ ├── nav-mobile.js │ ├── grpc.js │ ├── index-mobile.js │ ├── transaction.js │ ├── grpc-mobile.js │ ├── auth-mobile.js │ ├── payment.js │ ├── channel.js │ └── wallet.js └── package.json /README.md: -------------------------------------------------------------------------------- 1 | # lightning-dapp-web3 -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview main module that renders the app. 3 | */ 4 | 5 | import React, { Component } from 'react'; 6 | import Main from './view/main'; 7 | 8 | class App extends Component { 9 | render() { 10 | return
; 11 | } 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /src/action/ipc-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview a low level action to wrap communication to the lnd react native 3 | * module on mobile in order to provide a platform independant IPC api. 4 | */ 5 | 6 | class IpcAction { 7 | constructor(grpc) { 8 | this._grpc = grpc; 9 | } 10 | 11 | /** 12 | * A wrapper around electron's ipcRenderer send api that can be 13 | * reused wherever IPC to the main process is necessary. 14 | * @param {string} event The event name the main process listens to 15 | * @param {string} listen (optional) The response event name this process listens to 16 | * @param {*} payload The data sent over IPC 17 | * @return {Promise} 18 | */ 19 | send() { 20 | return Promise.resolve(); // not used on mobile 21 | } 22 | 23 | /** 24 | * A wrapper around electron's ipcRenderer listen api that can be 25 | * reused wherever listening to IPC from the main process is necessary. 26 | * @param {string} event The event name this process listens to 27 | * @param {Function} callback The event handler for incoming data 28 | * @return {undefined} 29 | */ 30 | listen(event, callback) { 31 | this._grpc._lndEvent.addListener(event, data => callback(event, data)); 32 | } 33 | } 34 | 35 | export default IpcAction; 36 | -------------------------------------------------------------------------------- /src/action/keychain-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview action to handle secure key storage to platform apis. 3 | */ 4 | 5 | const VERSION = '0'; 6 | const USER = 'lightning'; 7 | 8 | class KeychainAction { 9 | constructor(RNKeychain) { 10 | this._RNKeychain = RNKeychain; 11 | } 12 | 13 | /** 14 | * Store an item in the keychain. 15 | * @param {string} key The key by which to do a lookup 16 | * @param {string} value The value to be stored 17 | * @return {Promise} 18 | */ 19 | async setItem(key, value) { 20 | const options = { 21 | accessible: this._RNKeychain.WHEN_UNLOCKED_THIS_DEVICE_ONLY, 22 | }; 23 | const vKey = `${VERSION}_${key}`; 24 | await this._RNKeychain.setInternetCredentials(vKey, USER, value, options); 25 | } 26 | 27 | /** 28 | * Read an item stored in the keychain. 29 | * @param {string} key The key by which to do a lookup. 30 | * @return {Promise} The stored value 31 | */ 32 | async getItem(key) { 33 | const vKey = `${VERSION}_${key}`; 34 | const credentials = await this._RNKeychain.getInternetCredentials(vKey); 35 | if (credentials) { 36 | return credentials.password; 37 | } else { 38 | return null; 39 | } 40 | } 41 | } 42 | 43 | export default KeychainAction; 44 | -------------------------------------------------------------------------------- /src/action/ipc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview a low level action to wrap electron's IPC renderer api. 3 | */ 4 | 5 | class IpcAction { 6 | constructor(ipcRenderer) { 7 | this._ipcRenderer = ipcRenderer; 8 | } 9 | 10 | /** 11 | * A wrapper around electron's ipcRenderer send api that can be 12 | * reused wherever IPC to the main process is necessary. 13 | * @param {string} event The event name the main process listens to 14 | * @param {string} listen (optional) The response event name this process listens to 15 | * @param {*} payload The data sent over IPC 16 | * @return {Promise} 17 | */ 18 | send(event, listen, payload) { 19 | return new Promise((resolve, reject) => { 20 | this._ipcRenderer.send(event, payload); 21 | if (!listen) return resolve(); 22 | this._ipcRenderer.once(listen, (e, arg) => { 23 | if (arg.err) { 24 | reject(arg.err); 25 | } else { 26 | resolve(arg.response); 27 | } 28 | }); 29 | }); 30 | } 31 | 32 | /** 33 | * A wrapper around electron's ipcRenderer listen api that can be 34 | * reused wherever listening to IPC from the main process is necessary. 35 | * @param {string} event The event name this process listens to 36 | * @param {Function} callback The event handler for incoming data 37 | * @return {undefined} 38 | */ 39 | listen(event, callback) { 40 | this._ipcRenderer.on(event, callback); 41 | } 42 | } 43 | 44 | export default IpcAction; 45 | -------------------------------------------------------------------------------- /src/action/app-storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview repesents the local storage database on a user's device 3 | * which can be used to persist user settings on disk. 4 | */ 5 | 6 | import * as log from './log'; 7 | 8 | class AppStorage { 9 | constructor(store, AsyncStorage) { 10 | this._store = store; 11 | this._AsyncStorage = AsyncStorage; 12 | } 13 | 14 | /** 15 | * Read the user settings from disk and set them accordingly in the 16 | * application state. After the state has bee read to the global 17 | * `store` instance `store.loaded` is set to true. 18 | * @return {Promise} 19 | */ 20 | async restore() { 21 | try { 22 | const stateString = await this._AsyncStorage.getItem('settings'); 23 | if (!stateString) return; 24 | const state = JSON.parse(stateString); 25 | Object.keys(state).forEach(key => { 26 | if (typeof this._store.settings[key] !== 'undefined') { 27 | this._store.settings[key] = state[key]; 28 | } 29 | }); 30 | } catch (err) { 31 | log.error('Store load error', err); 32 | } finally { 33 | log.info('Loaded initial state'); 34 | this._store.loaded = true; 35 | } 36 | } 37 | 38 | /** 39 | * Persist the user settings to disk so that they may be read the 40 | * next time the application is opened by the user. 41 | * @return {Promise} 42 | */ 43 | async save() { 44 | try { 45 | const state = JSON.stringify(this._store.settings); 46 | await this._AsyncStorage.setItem('settings', state); 47 | log.info('Saved state'); 48 | } catch (error) { 49 | log.error('Store save error', error); 50 | } 51 | } 52 | 53 | /** 54 | * Delete all of the data in local storage completely. Should be used 55 | * carefully e.g. when the user wants to wipe the data on disk. 56 | * @return {Promise} 57 | */ 58 | async clear() { 59 | try { 60 | await this._AsyncStorage.clear(); 61 | log.info('State cleared'); 62 | } catch (error) { 63 | log.error('Store clear error', error); 64 | } 65 | } 66 | } 67 | 68 | export default AppStorage; 69 | -------------------------------------------------------------------------------- /src/action/setting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to handle settings state and save persist 3 | * them to disk when they are updated by the user. 4 | */ 5 | 6 | import { UNITS, FIATS } from '../config'; 7 | import localeCurrency from 'locale-currency'; 8 | import * as log from './log'; 9 | 10 | class SettingAction { 11 | constructor(store, wallet, db, ipc) { 12 | this._store = store; 13 | this._wallet = wallet; 14 | this._db = db; 15 | this._ipc = ipc; 16 | } 17 | 18 | /** 19 | * Set the bitcoin unit that is to be displayed in the UI and 20 | * perist the updated settings to disk. 21 | * @param {string} options.unit The bitcoin unit e.g. `btc` 22 | */ 23 | setBitcoinUnit({ unit }) { 24 | if (!UNITS[unit]) { 25 | throw new Error(`Invalid bitcoin unit: ${unit}`); 26 | } 27 | this._store.settings.unit = unit; 28 | this._db.save(); 29 | } 30 | 31 | /** 32 | * Set the fiat currency that is to be displayed in the UI and 33 | * perist the updated settings to disk. 34 | * @param {string} options.fiat The fiat currency e.g. `usd` 35 | */ 36 | async setFiatCurrency({ fiat }) { 37 | if (!FIATS[fiat]) { 38 | throw new Error(`Invalid fiat currency: ${fiat}`); 39 | } 40 | this._store.settings.fiat = fiat; 41 | await this._wallet.getExchangeRate(); 42 | this._db.save(); 43 | } 44 | 45 | /** 46 | * Set whether or not we're restoring the wallet. 47 | * @param {boolean} options.restoring Whether or not we're restoring. 48 | */ 49 | setRestoringWallet({ restoring }) { 50 | this._store.settings.restoring = restoring; 51 | } 52 | 53 | /** 54 | * Detect the user's local fiat currency based on their OS locale. 55 | * If the currency is not supported use the default currency `usd`. 56 | * @return {Promise} 57 | */ 58 | async detectLocalCurrency() { 59 | try { 60 | let locale = await this._ipc.send('locale-get', 'locale'); 61 | const fiat = localeCurrency.getCurrency(locale).toLowerCase(); 62 | await this.setFiatCurrency({ fiat }); 63 | } catch (err) { 64 | log.error('Detecting local currency failed', err); 65 | } 66 | } 67 | } 68 | 69 | export default SettingAction; 70 | -------------------------------------------------------------------------------- /src/action/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions for logging to the cli. This module can be regarded as a 3 | * global singleton and can be imported directly as an ES6 module at the top of 4 | * other actions for easier use as it stores instances of dependencies in closure 5 | * variables. 6 | */ 7 | 8 | import { MAX_LOG_LENGTH } from '../config'; 9 | 10 | let _store; 11 | let _ipc; 12 | let _printErrObj; 13 | 14 | /** 15 | * Log an info event e.g. when something relevant but non-critical happens. 16 | * The data is also sent to the electron main process via IPC to be logged 17 | * to standard output. 18 | * @param {...string|Object} args An info message or object to be logged 19 | * @return {undefined} 20 | */ 21 | export function info(...args) { 22 | console.log(...args); 23 | _ipc && _ipc.send('log', null, args); 24 | } 25 | 26 | /** 27 | * Log an error event e.g. when something does not work as planned. Apart 28 | * from logging the error on the console this also appends the error to the 29 | * logs which are displayed to the user in the Logs/CLI view. 30 | * The data is also sent to the electron main process via IPC to be logged 31 | * to standard output. 32 | * @param {...string|Object} args An error message of Error object 33 | * @return {undefined} 34 | */ 35 | export function error(...args) { 36 | console.error(...args); 37 | pushLogs(''); // newline 38 | pushLogs(`ERROR: ${args[0]}`); 39 | for (let i = 1; i < args.length; i++) { 40 | pushLogs( 41 | JSON.stringify( 42 | _printErrObj ? args[i] : { message: args[i].message }, 43 | null, 44 | ' ' 45 | ) 46 | ); 47 | } 48 | pushLogs(''); // newline 49 | _ipc && _ipc.send('log-error', null, args); 50 | } 51 | 52 | function pushLogs(message) { 53 | if (!_store) return; 54 | _store.logs += '\n' + message.replace(/\s+$/, ''); 55 | const len = _store.logs.length; 56 | if (len > MAX_LOG_LENGTH) { 57 | _store.logs = _store.logs.substring(len - MAX_LOG_LENGTH, len); 58 | } 59 | } 60 | 61 | class LogAction { 62 | constructor(store, ipc, printErrObj = true) { 63 | _store = store; 64 | _ipc = ipc; 65 | _printErrObj = printErrObj; 66 | _ipc.listen('logs', (event, message) => pushLogs(message)); 67 | _ipc.send('logs-ready', null, true); 68 | } 69 | } 70 | 71 | export default LogAction; 72 | -------------------------------------------------------------------------------- /src/action/notification.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to display notifications to the user in case something 3 | * relevant happens or an error occurs. Notifications are display in the notification 4 | * bar at the top of the screen for a brief time and can be listed in the notification 5 | * view later. 6 | */ 7 | 8 | import * as log from './log'; 9 | import { NOTIFICATION_DELAY } from '../config'; 10 | 11 | class NotificationAction { 12 | constructor(store, nav) { 13 | this._store = store; 14 | this._nav = nav; 15 | } 16 | 17 | /** 18 | * Set the dropdown component used on mobile. 19 | * @param {Object} dropdown The component reference 20 | */ 21 | setDropdown(dropdown) { 22 | this._dropdown = dropdown; 23 | } 24 | 25 | /** 26 | * The main api used to display notifications thorughout the application. Several 27 | * types of notifications can be displayed including `info` `error` or `success`. 28 | * If the wait flag is set the notification bar will display a spinner e.g. when 29 | * something is loading. If an error is provided that will be logged to the cli. 30 | * Also an action handler can be passed which will render a button e.g. for error 31 | * resolution. A notification is displayed for a few seconds. 32 | * @param {string} options.type Either `info` `error` or `success` 33 | * @param {string} options.msg The notification message 34 | * @param {boolean} options.wait If a spinner should be displayed 35 | * @param {Error} options.err The error object to be logged 36 | * @param {Function} options.handler Called when the button is pressed 37 | * @param {string} options.handlerLbl The action handler button text 38 | * @return {undefined} 39 | */ 40 | display({ type, msg, wait, err, handler, handlerLbl }) { 41 | if (err) log.info(msg, err); 42 | this._store.notifications.push({ 43 | type: type || (err ? 'error' : 'info'), 44 | message: msg, 45 | waiting: wait, 46 | date: new Date(), 47 | handler: handler || (err ? () => this._nav.goCLI() : null), 48 | handlerLbl: handlerLbl || (err ? 'Show error logs' : null), 49 | display: true, 50 | }); 51 | if (!wait) this._store.unseenNtfnCount += 1; 52 | clearTimeout(this.tdisplay); 53 | this.tdisplay = setTimeout(() => this.close(), NOTIFICATION_DELAY); 54 | // render dropdown on mobile 55 | if (this._dropdown) { 56 | this._dropdown.alertWithType('custom', '', msg); 57 | } 58 | } 59 | 60 | /** 61 | * Called after the notification bar display time has run out to stop rendering 62 | * the notification in the notification bar. 63 | * @return {undefined} 64 | */ 65 | close() { 66 | this._store.notifications.forEach(n => { 67 | n.display = false; 68 | }); 69 | } 70 | } 71 | 72 | export default NotificationAction; 73 | -------------------------------------------------------------------------------- /src/action/file-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions wrapping file I/O operations on mobile. 3 | */ 4 | 5 | import * as log from './log'; 6 | 7 | class FileAction { 8 | constructor(store, FS, Share) { 9 | this._store = store; 10 | this._FS = FS; 11 | this._Share = Share; 12 | } 13 | 14 | /** 15 | * Gets the path of the lnd directory where `logs` and `data` are stored. 16 | * @return {string} 17 | */ 18 | get lndDir() { 19 | return this._FS.DocumentDirectoryPath; 20 | } 21 | 22 | /** 23 | * Get the path of the app's directory on the device's external storage. 24 | * @return {string} 25 | */ 26 | get externalStorageDir() { 27 | return this._FS.ExternalStorageDirectoryPath; 28 | } 29 | 30 | // 31 | // Log file actions 32 | // 33 | 34 | /** 35 | * Gets the path of the current network's log file. 36 | * @return {string} 37 | */ 38 | get logsPath() { 39 | const { network } = this._store; 40 | return `${this.lndDir}/logs/bitcoin/${network}/lnd.log`; 41 | } 42 | 43 | /** 44 | * Shares the log file using whatever native share function we have. 45 | * @return {Promise} 46 | */ 47 | async shareLogs() { 48 | try { 49 | await this._Share.open({ 50 | url: `file://${this.logsPath}`, 51 | type: 'text/plain', 52 | }); 53 | } catch (err) { 54 | log.error('Exporting logs failed', err); 55 | } 56 | } 57 | 58 | // 59 | // Wallet DB actions 60 | // 61 | 62 | /** 63 | * Delete the wallet.db file. This allows the user to restore their wallet 64 | * (including channel state) from the seed if they've forgotten the pin. 65 | * @return {Promise} 66 | */ 67 | async deleteWalletDB(network) { 68 | const path = `${this.lndDir}/data/chain/bitcoin/${network}/wallet.db`; 69 | try { 70 | await this._FS.unlink(path); 71 | } catch (err) { 72 | log.info(`No ${network} wallet to delete.`); 73 | } 74 | } 75 | 76 | // 77 | // Static Channel Backup (SCB) actions 78 | // 79 | 80 | get scbPath() { 81 | const { network } = this._store; 82 | return `${this.lndDir}/data/chain/bitcoin/${network}/channel.backup`; 83 | } 84 | 85 | get scbExternalDir() { 86 | const { network } = this._store; 87 | return `${this.externalStorageDir}/Lightning/${network}`; 88 | } 89 | 90 | get scbExternalPath() { 91 | return `${this.scbExternalDir}/channel.backup`; 92 | } 93 | 94 | async readSCB() { 95 | return this._FS.readFile(this.scbPath, 'base64'); 96 | } 97 | 98 | async copySCBToExternalStorage() { 99 | const exists = await this._FS.exists(this.scbPath); 100 | if (!exists) return; 101 | await this._FS.mkdir(this.scbExternalDir); 102 | await this._FS.copyFile(this.scbPath, this.scbExternalPath); 103 | } 104 | 105 | async readSCBFromExternalStorage() { 106 | const exists = await this._FS.exists(this.scbExternalPath); 107 | if (!exists) return; 108 | return this._FS.readFile(this.scbExternalPath, 'base64'); 109 | } 110 | } 111 | 112 | export default FileAction; 113 | -------------------------------------------------------------------------------- /src/action/info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview the info actions are used to fetch general details about the 3 | * state of the lnd such as the public key as well as synchronization state and 4 | * the current block height. 5 | */ 6 | 7 | import { observe } from 'mobx'; 8 | import { poll } from '../helper'; 9 | import * as log from './log'; 10 | 11 | class InfoAction { 12 | constructor(store, grpc, nav, notification) { 13 | this._store = store; 14 | this._nav = nav; 15 | this._grpc = grpc; 16 | this._notification = notification; 17 | } 18 | 19 | /** 20 | * Fetches the current details of the lnd node and sets the corresponding 21 | * store parameters. This api is polled at the beginning of app initialization 22 | * until lnd has finished syncing the chain to the connected bitcoin full node. 23 | * @return {Promise} 24 | */ 25 | async getInfo() { 26 | try { 27 | const response = await this._grpc.sendCommand('getInfo'); 28 | this._store.pubKey = response.identityPubkey; 29 | this._store.syncedToChain = response.syncedToChain; 30 | this._store.blockHeight = response.blockHeight; 31 | this._store.network = response.chains[0].network; 32 | if (this.startingSyncTimestamp === undefined) { 33 | this.startingSyncTimestamp = response.bestHeaderTimestamp || 0; 34 | } 35 | if (!response.syncedToChain) { 36 | this._notification.display({ 37 | msg: `Syncing to chain (block: ${response.blockHeight})`, 38 | wait: true, 39 | }); 40 | this._store.percentSynced = this.calcPercentSynced(response); 41 | } else { 42 | this._store.settings.restoring = false; 43 | this._notification.display({ 44 | type: 'success', 45 | msg: 'Syncing complete', 46 | }); 47 | } 48 | return response.syncedToChain; 49 | } catch (err) { 50 | log.error('Getting node info failed', err); 51 | } 52 | } 53 | 54 | /** 55 | * Poll the getInfo api until syncedToChain is true. 56 | * @return {Promise} 57 | */ 58 | async pollInfo() { 59 | await poll(() => this.getInfo()); 60 | } 61 | 62 | /** 63 | * A navigation helper called during the app onboarding process. The loader 64 | * screen indicating the syncing progress in displayed until syncing has 65 | * completed `syncedToChain` is set to true. After that the user is taken 66 | * to the home screen. 67 | * @return {undefined} 68 | */ 69 | initLoaderSyncing() { 70 | if (this._store.syncedToChain) { 71 | this._nav.goHome(); 72 | } else { 73 | this._nav.goLoaderSyncing(); 74 | observe(this._store, 'syncedToChain', () => this._nav.goHome()); 75 | } 76 | } 77 | 78 | /** 79 | * An internal helper function to approximate the current progress while 80 | * syncing Neutrino to the full node. 81 | * @param {Object} response The getInfo's grpc api response 82 | * @return {number} The percrentage a number between 0 and 1 83 | */ 84 | calcPercentSynced(response) { 85 | const bestHeaderTimestamp = response.bestHeaderTimestamp; 86 | const currTimestamp = new Date().getTime() / 1000; 87 | const progressSoFar = bestHeaderTimestamp 88 | ? bestHeaderTimestamp - this.startingSyncTimestamp 89 | : 0; 90 | const totalProgress = currTimestamp - this.startingSyncTimestamp || 0.001; 91 | const percentSynced = (progressSoFar * 1.0) / totalProgress; 92 | return percentSynced; 93 | } 94 | } 95 | 96 | export default InfoAction; 97 | -------------------------------------------------------------------------------- /src/action/nav.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions for wrap navigation between views behing a platform 3 | * independant api. These action should be pretty dumb and only change the 4 | * route to be rendered in the user interface. 5 | */ 6 | 7 | class NavAction { 8 | constructor(store) { 9 | this._store = store; 10 | } 11 | 12 | goLoader() { 13 | this._store.route = 'Loader'; 14 | } 15 | 16 | goSelectSeed() { 17 | this._store.route = 'SelectSeed'; 18 | } 19 | 20 | goSeed() { 21 | this._store.route = 'Seed'; 22 | } 23 | 24 | goSeedVerify() { 25 | this._store.route = 'SeedVerify'; 26 | } 27 | 28 | goRestoreSeed() { 29 | this._store.route = 'RestoreSeed'; 30 | } 31 | 32 | goSeedSuccess() { 33 | this._store.route = 'SeedSuccess'; 34 | } 35 | 36 | goSetPassword() { 37 | this._store.route = 'SetPassword'; 38 | } 39 | 40 | goSetPasswordConfirm() { 41 | this._store.route = 'SetPasswordConfirm'; 42 | } 43 | 44 | goPassword() { 45 | this._store.route = 'Password'; 46 | } 47 | 48 | goResetPasswordCurrent() { 49 | this._store.route = 'ResetPasswordCurrent'; 50 | } 51 | 52 | goResetPasswordNew() { 53 | this._store.route = 'ResetPasswordNew'; 54 | } 55 | 56 | goResetPasswordConfirm() { 57 | this._store.route = 'ResetPasswordConfirm'; 58 | } 59 | 60 | goResetPasswordSaved() { 61 | this._store.route = 'ResetPasswordSaved'; 62 | } 63 | 64 | goNewAddress() { 65 | this._store.route = 'NewAddress'; 66 | } 67 | 68 | goLoaderSyncing() { 69 | this._store.route = 'LoaderSyncing'; 70 | } 71 | 72 | goWait() { 73 | this._store.route = 'Wait'; 74 | } 75 | 76 | goHome() { 77 | this._store.route = 'Home'; 78 | } 79 | 80 | goPay() { 81 | this._store.route = 'Pay'; 82 | } 83 | 84 | goPayLightningConfirm() { 85 | this._store.route = 'PayLightningConfirm'; 86 | } 87 | 88 | goPayLightningDone() { 89 | this._store.route = 'PayLightningDone'; 90 | } 91 | 92 | goPaymentFailed() { 93 | this._store.route = 'PaymentFailed'; 94 | } 95 | 96 | goPayBitcoin() { 97 | this._store.route = 'PayBitcoin'; 98 | } 99 | 100 | goPayBitcoinConfirm() { 101 | this._store.route = 'PayBitcoinConfirm'; 102 | } 103 | 104 | goPayBitcoinDone() { 105 | this._store.route = 'PayBitcoinDone'; 106 | } 107 | 108 | goInvoice() { 109 | this._store.route = 'Invoice'; 110 | } 111 | 112 | goInvoiceQR() { 113 | this._store.displayCopied = false; 114 | this._store.route = 'InvoiceQR'; 115 | } 116 | 117 | goChannels() { 118 | this._store.route = 'Channels'; 119 | } 120 | 121 | goChannelDetail() { 122 | this._store.route = 'ChannelDetail'; 123 | } 124 | 125 | goChannelDelete() { 126 | this._store.route = 'ChannelDelete'; 127 | } 128 | 129 | goChannelCreate() { 130 | this._store.route = 'ChannelCreate'; 131 | } 132 | 133 | goTransactions() { 134 | this._store.route = 'Transactions'; 135 | } 136 | 137 | goTransactionDetail() { 138 | this._store.route = 'TransactionDetail'; 139 | } 140 | 141 | goNotifications() { 142 | this._store.unseenNtfnCount = 0; 143 | this._store.route = 'Notifications'; 144 | } 145 | 146 | goSettings() { 147 | this._store.route = 'Settings'; 148 | } 149 | 150 | goSettingsUnit() { 151 | this._store.route = 'SettingsUnit'; 152 | } 153 | 154 | goSettingsFiat() { 155 | this._store.route = 'SettingsFiat'; 156 | } 157 | 158 | goCLI() { 159 | this._store.route = 'CLI'; 160 | } 161 | 162 | goCreateChannel() { 163 | this._store.route = 'CreateChannel'; 164 | } 165 | 166 | goDeposit() { 167 | this._store.displayCopied = false; 168 | this._store.route = 'Deposit'; 169 | } 170 | } 171 | 172 | export default NavAction; 173 | -------------------------------------------------------------------------------- /src/action/autopilot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions related to autopilot, such as toggling 3 | * whether autopilot should open channels. 4 | */ 5 | 6 | import { ATPL_DELAY } from '../config'; 7 | import { poll, checkHttpStatus } from '../helper'; 8 | import * as log from './log'; 9 | 10 | class AtplAction { 11 | constructor(store, grpc, db, notification) { 12 | this._store = store; 13 | this._grpc = grpc; 14 | this._db = db; 15 | this._notification = notification; 16 | } 17 | 18 | /** 19 | * Initialize autopilot from the stored settings and enable it via grpc 20 | * depending on if the user has enabled it in the last session. Fetch node 21 | * scores are fetched from an api to inform channel selection. 22 | * @return {Promise} 23 | */ 24 | async init() { 25 | await this.updateNodeScores(); 26 | if (this._store.settings.autopilot) { 27 | await this._setStatus(true); 28 | } 29 | await poll(() => this.updateNodeScores(), ATPL_DELAY); 30 | } 31 | 32 | /** 33 | * Toggle whether autopilot is turned on and save user settings if 34 | * the grpc call was successful. 35 | * @return {Promise} 36 | */ 37 | async toggle() { 38 | const newState = !this._store.settings.autopilot; 39 | const success = await this._setStatus(newState); 40 | if (success) { 41 | this._store.settings.autopilot = newState; 42 | this._db.save(); 43 | } 44 | } 45 | 46 | /** 47 | * Set whether autopilot is enabled or disabled. 48 | * @param {boolean} enable Whether autopilot should be enabled. 49 | * @return {Promise} 50 | */ 51 | async _setStatus(enable) { 52 | try { 53 | await this._grpc.sendAutopilotCommand('modifyStatus', { enable }); 54 | return true; 55 | } catch (err) { 56 | this._notification.display({ msg: 'Error toggling autopilot', err }); 57 | } 58 | } 59 | 60 | /** 61 | * Update node scores to get better channels via autopilot. 62 | * @return {Promise} 63 | */ 64 | async updateNodeScores() { 65 | try { 66 | await this._checkNetwork(); 67 | const scores = await this._readNodeScores(); 68 | await this._setNodeScores(scores); 69 | } catch (err) { 70 | log.error('Updating autopilot scores failed', err); 71 | } 72 | } 73 | 74 | async _checkNetwork() { 75 | if (!this._store.network) { 76 | throw new Error('Could not read network'); 77 | } 78 | } 79 | 80 | async _readNodeScores() { 81 | const { network, settings } = this._store; 82 | try { 83 | settings.nodeScores[network] = await this._fetchNodeScores(network); 84 | this._db.save(); 85 | } catch (err) { 86 | log.error('Fetching node scores failed', err); 87 | } 88 | return settings.nodeScores[network]; 89 | } 90 | 91 | async _fetchNodeScores(network) { 92 | const baseUri = 'https://nodes.lightning.computer/availability/v1'; 93 | const uri = `${baseUri}/btc${network === 'testnet' ? 'testnet' : ''}.json`; 94 | const response = checkHttpStatus(await fetch(uri)); 95 | return this._formatNodesScores((await response.json()).scores); 96 | } 97 | 98 | _formatNodesScores(jsonScores) { 99 | return jsonScores.reduce((map, { public_key, score }) => { 100 | if (typeof public_key !== 'string' || !Number.isInteger(score)) { 101 | throw new Error('Invalid node score format!'); 102 | } 103 | map[public_key] = score / 100000000.0; 104 | return map; 105 | }, {}); 106 | } 107 | 108 | async _setNodeScores(scores) { 109 | if (!scores) { 110 | throw new Error('Node scores are emtpy'); 111 | } 112 | await this._grpc.sendAutopilotCommand('setScores', { 113 | heuristic: 'externalscore', 114 | scores, 115 | }); 116 | } 117 | } 118 | 119 | export default AtplAction; 120 | -------------------------------------------------------------------------------- /src/action/invoice.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions required to generate a lightning payment request 3 | * a.k.a invoice that can be sent to another user. 4 | */ 5 | 6 | import { PREFIX_URI } from '../config'; 7 | import { toSatoshis } from '../helper'; 8 | 9 | class InvoiceAction { 10 | constructor(store, grpc, nav, notification, clipboard) { 11 | this._store = store; 12 | this._grpc = grpc; 13 | this._nav = nav; 14 | this._notification = notification; 15 | this._clipboard = clipboard; 16 | } 17 | 18 | /** 19 | * Initialize the invoice view by resetting input values 20 | * and then navigating to the view. 21 | * @return {undefined} 22 | */ 23 | init() { 24 | this._store.invoice.amount = ''; 25 | this._store.invoice.note = ''; 26 | this._store.invoice.encoded = ''; 27 | this._store.invoice.uri = ''; 28 | this._nav.goInvoice(); 29 | } 30 | 31 | /** 32 | * Set the amount input for the invoice view. This amount 33 | * is either in btc or fiat depending on user settings. 34 | * @param {string} options.amount The string formatted number 35 | */ 36 | setAmount({ amount }) { 37 | this._store.invoice.amount = amount; 38 | } 39 | 40 | /** 41 | * Set the node input for the invoice view. This is used as 42 | * the description in the invoice later viewed by the payer. 43 | * @param {string} options.note The invoice description 44 | */ 45 | setNote({ note }) { 46 | this._store.invoice.note = note; 47 | } 48 | 49 | /** 50 | * Read the input values amount and note and generates an encoded 51 | * payment request via the gprc api. The invoice uri is also set 52 | * which can be rendered in a QR code for scanning. After the values 53 | * are set on the store the user is navigated to the invoice QR view 54 | * which displays the QR for consumption by the payer. 55 | * The invoice is set private since it should contain a routing hint 56 | * for private channels. 57 | * This action can be called from a view event handler as does all 58 | * the necessary error handling and notification display. 59 | * @return {Promise} 60 | */ 61 | async generateUri() { 62 | try { 63 | const { invoice, settings } = this._store; 64 | const satAmount = toSatoshis(invoice.amount, settings); 65 | this.checkAmount({ satAmount }); 66 | const response = await this._grpc.sendCommand('addInvoice', { 67 | value: satAmount, 68 | memo: invoice.note, 69 | expiry: 172800, 70 | private: true, 71 | }); 72 | invoice.encoded = response.paymentRequest; 73 | invoice.uri = `${PREFIX_URI}${invoice.encoded}`; 74 | this._nav.goInvoiceQR(); 75 | } catch (err) { 76 | this._notification.display({ msg: 'Creating invoice failed!', err }); 77 | } 78 | } 79 | 80 | /** 81 | * Verify that the user has a channel with enough receive capacity to 82 | * receive the given amount. 83 | * @param {number} options.satAmount The amount to receive. 84 | * @return {undefined} 85 | */ 86 | checkAmount({ satAmount }) { 87 | const { channels } = this._store; 88 | const hasInbound = channels.find(c => c.remoteBalance >= satAmount); 89 | if (hasInbound) { 90 | return; 91 | } 92 | this._notification.display({ 93 | msg: "You don't have enough inbound capacity to receive this payment.", 94 | }); 95 | } 96 | 97 | /** 98 | * A simple wrapper around the react native clipboard api. This can 99 | * be called when a string like a payment request or address should be 100 | * copied and pasted from the application UI. 101 | * @param {string} options.text The payload to be copied to the clipboard 102 | * @return {undefined} 103 | */ 104 | toClipboard({ text }) { 105 | this._clipboard.setString(text); 106 | this._store.displayCopied = true; 107 | } 108 | } 109 | 110 | export default InvoiceAction; 111 | -------------------------------------------------------------------------------- /src/action/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview this is the main action module where all actions are initilized 3 | * by injecting dependencies and then triggering startup actions when certain 4 | * flags on the global store are set to true. 5 | */ 6 | 7 | import { when } from 'mobx'; 8 | import { AsyncStorage, Clipboard } from 'react-native'; 9 | import { nap } from '../helper'; 10 | import store from '../store'; 11 | import AppStorage from './app-storage'; 12 | import IpcAction from './ipc'; 13 | import GrpcAction from './grpc'; 14 | import NavAction from './nav'; 15 | import WalletAction from './wallet'; 16 | import LogAction from './log'; 17 | import InfoAction from './info'; 18 | import NotificationAction from './notification'; 19 | import ChannelAction from './channel'; 20 | import TransactionAction from './transaction'; 21 | import PaymentAction from './payment'; 22 | import InvoiceAction from './invoice'; 23 | import SettingAction from './setting'; 24 | import AtplAction from './autopilot'; 25 | 26 | // 27 | // Inject dependencies 28 | // 29 | 30 | store.init(); // initialize computed values 31 | 32 | export const ipc = new IpcAction(window.ipcRenderer); 33 | export const db = new AppStorage(store, AsyncStorage); 34 | export const log = new LogAction(store, ipc); 35 | export const nav = new NavAction(store); 36 | export const grpc = new GrpcAction(store, ipc); 37 | export const notify = new NotificationAction(store, nav); 38 | export const wallet = new WalletAction(store, grpc, db, nav, notify); 39 | export const info = new InfoAction(store, grpc, nav, notify); 40 | export const transaction = new TransactionAction(store, grpc, nav, notify); 41 | export const channel = new ChannelAction(store, grpc, nav, notify); 42 | export const invoice = new InvoiceAction(store, grpc, nav, notify, Clipboard); 43 | export const payment = new PaymentAction(store, grpc, nav, notify, Clipboard); 44 | export const setting = new SettingAction(store, wallet, db, ipc); 45 | export const autopilot = new AtplAction(store, grpc, db, notify); 46 | 47 | payment.listenForUrl(ipc); // enable incoming url handler 48 | 49 | // 50 | // Init actions 51 | // 52 | 53 | db.restore(); // read user settings from disk 54 | 55 | /** 56 | * Triggered after user settings are restored from disk. 57 | */ 58 | when(() => store.loaded, () => grpc.initUnlocker()); 59 | 60 | /** 61 | * Triggered after the wallet unlocker grpc client is initialized. 62 | */ 63 | when(() => store.unlockerReady, () => wallet.init()); 64 | 65 | /** 66 | * Triggered the first time the app was started e.g. to set the 67 | * local fiat currency only once. 68 | */ 69 | when(() => store.firstStart, () => setting.detectLocalCurrency()); 70 | 71 | /** 72 | * Triggered after the user's password has unlocked the wallet 73 | * or a user's password has been successfully reset. 74 | */ 75 | when( 76 | () => store.walletUnlocked, 77 | async () => { 78 | await nap(); 79 | await grpc.closeUnlocker(); 80 | await grpc.initLnd(); 81 | await grpc.initAutopilot(); 82 | } 83 | ); 84 | 85 | /** 86 | * Triggered once the main lnd grpc client is initialized. This is when 87 | * the user can really begin to interact with the application and calls 88 | * to and from lnd can be done. The display the current state of the 89 | * lnd node all balances, channels and transactions are fetched. 90 | */ 91 | when( 92 | () => store.lndReady, 93 | () => { 94 | wallet.pollBalances(); 95 | wallet.pollExchangeRate(); 96 | channel.pollChannels(); 97 | transaction.update(); 98 | transaction.subscribeTransactions(); 99 | transaction.subscribeInvoices(); 100 | info.pollInfo(); 101 | } 102 | ); 103 | 104 | /** 105 | * Initialize autopilot after syncing is finished and the grpc client 106 | * is ready 107 | */ 108 | when( 109 | () => store.syncedToChain && store.network && store.autopilotReady, 110 | async () => { 111 | await nap(); 112 | autopilot.init(); 113 | } 114 | ); 115 | -------------------------------------------------------------------------------- /src/action/backup-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview action to handle static channel backup (SCB) to local 3 | * storage options as well as to cloud storage. On iOS the iCloud key/value 4 | * store is used. On Android we store backups to external storage on device 5 | * for local backups. 6 | */ 7 | 8 | import * as log from './log'; 9 | 10 | const SCB_KEY = 'channel.backup'; 11 | 12 | class BackupAction { 13 | constructor(grpc, file, Platform, DeviceInfo, Permissions, iCloudStorage) { 14 | this._grpc = grpc; 15 | this._file = file; 16 | this._Platform = Platform; 17 | this._DeviceInfo = DeviceInfo; 18 | this._Permissions = Permissions; 19 | this._iCloudStorage = iCloudStorage; 20 | } 21 | 22 | // 23 | // Backup actions 24 | // 25 | 26 | /** 27 | * Push a channel backup to external storage or iCloud. 28 | * @return {Promise} 29 | */ 30 | async pushChannelBackup() { 31 | if (this._Platform.OS === 'ios') { 32 | await this.pushToICloud(); 33 | } else if (this._Platform.OS === 'android') { 34 | await this.pushToExternalStorage(); 35 | } 36 | } 37 | 38 | async pushToICloud() { 39 | try { 40 | const scbBase64 = await this._file.readSCB(); 41 | if (!scbBase64) return; 42 | const json = this.stringify(scbBase64); 43 | await this._iCloudStorage.setItem(this.itemKey, json); 44 | } catch (err) { 45 | log.error('Uploading channel backup to iCloud failed', err); 46 | } 47 | } 48 | 49 | async requestPermissionForExternalStorage() { 50 | const granted = await await this._Permissions.request( 51 | this._Permissions.PERMISSIONS.WRITE_EXTERNAL_STORAGE 52 | ); 53 | return granted === this._Permissions.RESULTS.GRANTED; 54 | } 55 | 56 | async pushToExternalStorage() { 57 | const permission = await this.requestPermissionForExternalStorage(); 58 | if (!permission) { 59 | log.info('Skipping channel backup due to missing permissions'); 60 | return; 61 | } 62 | try { 63 | await this._file.copySCBToExternalStorage(); 64 | } catch (err) { 65 | log.error('Copying channel backup to external storage failed', err); 66 | } 67 | } 68 | 69 | /** 70 | * Subscribe to channel backup updates. If a new one comes in, back up the 71 | * latest update. 72 | * @return {undefined} 73 | */ 74 | async subscribeChannelBackups() { 75 | const stream = this._grpc.sendStreamCommand('subscribeChannelBackups'); 76 | stream.on('data', () => this.pushChannelBackup()); 77 | stream.on('error', err => log.error('Channel backup error:', err)); 78 | stream.on('status', status => log.info(`Channel backup status: ${status}`)); 79 | } 80 | 81 | // 82 | // Restore actions 83 | // 84 | 85 | async fetchChannelBackup() { 86 | let scbBase64; 87 | if (this._Platform.OS === 'ios') { 88 | scbBase64 = await this.fetchFromICloud(); 89 | } else if (this._Platform.OS === 'android') { 90 | scbBase64 = await this.fetchFromExternalStorage(); 91 | } 92 | return scbBase64 ? Buffer.from(scbBase64, 'base64') : null; 93 | } 94 | 95 | async fetchFromICloud() { 96 | try { 97 | const json = await this._iCloudStorage.getItem(this.itemKey); 98 | return json ? this.parse(json).data : null; 99 | } catch (err) { 100 | log.info(`Failed to read channel backup from iCloud: ${err.message}`); 101 | } 102 | } 103 | 104 | async fetchFromExternalStorage() { 105 | const permission = await this.requestPermissionForExternalStorage(); 106 | if (!permission) { 107 | log.info('Skipping channel restore: missing storage permissions'); 108 | return; 109 | } 110 | try { 111 | return this._file.readSCBFromExternalStorage(); 112 | } catch (err) { 113 | log.info(`Failed to read channel backup from external: ${err.message}`); 114 | } 115 | } 116 | 117 | // 118 | // Helper functions 119 | // 120 | 121 | get shortId() { 122 | return this._DeviceInfo 123 | .getUniqueID() 124 | .replace(/-/g, '') 125 | .slice(0, 7) 126 | .toLowerCase(); 127 | } 128 | 129 | get itemKey() { 130 | return `${this.shortId}_${SCB_KEY}`; 131 | } 132 | 133 | stringify(scbBase64) { 134 | return JSON.stringify({ 135 | device: this._DeviceInfo.getDeviceId(), 136 | data: scbBase64, 137 | time: new Date().toISOString(), 138 | }); 139 | } 140 | 141 | parse(json) { 142 | return JSON.parse(json); 143 | } 144 | } 145 | 146 | export default BackupAction; 147 | -------------------------------------------------------------------------------- /src/action/nav-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions that wrap navigation for mobile between views 3 | * behing a platform independant api. These actions should be pretty dumb 4 | * and only change the route to be rendered in the user interface. 5 | */ 6 | 7 | class NavAction { 8 | constructor(store, NavigationActions, StackActions) { 9 | this._store = store; 10 | this._navActions = NavigationActions; 11 | this._stackActions = StackActions; 12 | } 13 | 14 | setTopLevelNavigator(navigatorRef) { 15 | this._navigate = routeName => 16 | navigatorRef.dispatch(this._navActions.navigate({ routeName })); 17 | 18 | this._back = () => navigatorRef.dispatch(this._navActions.back()); 19 | 20 | this._reset = (stackName, routeName) => 21 | navigatorRef.dispatch( 22 | this._stackActions.reset({ 23 | index: 0, 24 | actions: [ 25 | this._navActions.navigate({ 26 | routeName: stackName, 27 | action: this._navActions.navigate({ routeName }), 28 | }), 29 | ], 30 | }) 31 | ); 32 | 33 | this._store.navReady = true; 34 | } 35 | 36 | goBack() { 37 | this._back(); 38 | } 39 | 40 | goLoader() { 41 | this._navigate('Loader'); 42 | } 43 | 44 | goSelectSeed() { 45 | this._navigate('SelectSeed'); 46 | } 47 | 48 | goSeedIntro() { 49 | this._navigate('SeedIntro'); 50 | } 51 | 52 | goSeed() { 53 | this._navigate('Seed'); 54 | } 55 | 56 | goSeedVerify() { 57 | this._navigate('SeedVerify'); 58 | } 59 | 60 | goRestoreSeed() { 61 | this._navigate('RestoreSeed'); 62 | } 63 | 64 | goSeedSuccess() { 65 | this._navigate('SeedSuccess'); 66 | } 67 | 68 | goSetPassword() { 69 | this._navigate('SetPassword'); 70 | } 71 | 72 | goSetPasswordConfirm() { 73 | this._navigate('SetPasswordConfirm'); 74 | } 75 | 76 | goPassword() { 77 | this._navigate('Password'); 78 | } 79 | 80 | goResetPasswordCurrent() { 81 | this._navigate('ResetPasswordCurrent'); 82 | } 83 | 84 | goResetPasswordNew() { 85 | this._navigate('ResetPasswordNew'); 86 | } 87 | 88 | goResetPasswordConfirm() { 89 | this._navigate('ResetPasswordConfirm'); 90 | } 91 | 92 | goResetPasswordSaved() { 93 | this._navigate('ResetPasswordSaved'); 94 | } 95 | 96 | goNewAddress() { 97 | this._navigate('NewAddress'); 98 | } 99 | 100 | goSelectAutopilot() { 101 | this._navigate('SelectAutopilot'); 102 | } 103 | 104 | goLoaderSyncing() { 105 | this._navigate('LoaderSyncing'); 106 | this._reset('Main', 'LoaderSyncing'); 107 | } 108 | 109 | goWait() { 110 | this._navigate('Wait'); 111 | } 112 | 113 | goHome() { 114 | this._navigate('Home'); 115 | this._reset('Main', 'Home'); 116 | } 117 | 118 | goPay() { 119 | this._navigate('Pay'); 120 | } 121 | 122 | goPayLightningConfirm() { 123 | this._navigate('PayLightningConfirm'); 124 | } 125 | 126 | goPayLightningDone() { 127 | this._navigate('PayLightningDone'); 128 | } 129 | 130 | goPaymentFailed() { 131 | this._navigate('PaymentFailed'); 132 | } 133 | 134 | goPayBitcoin() { 135 | this._navigate('PayBitcoin'); 136 | } 137 | 138 | goPayBitcoinConfirm() { 139 | this._navigate('PayBitcoinConfirm'); 140 | } 141 | 142 | goPayBitcoinDone() { 143 | this._navigate('PayBitcoinDone'); 144 | } 145 | 146 | goInvoice() { 147 | this._navigate('Invoice'); 148 | } 149 | 150 | goInvoiceQR() { 151 | this._store.displayCopied = false; 152 | this._navigate('InvoiceQR'); 153 | } 154 | 155 | goChannels() { 156 | this._navigate('Channels'); 157 | } 158 | 159 | goChannelDetail() { 160 | this._navigate('ChannelDetail'); 161 | } 162 | 163 | goChannelDelete() { 164 | this._navigate('ChannelDelete'); 165 | } 166 | 167 | goChannelCreate() { 168 | this._navigate('ChannelCreate'); 169 | } 170 | 171 | goTransactions() { 172 | this._navigate('Transactions'); 173 | } 174 | 175 | goTransactionDetail() { 176 | this._navigate('TransactionDetail'); 177 | } 178 | 179 | goNotifications() { 180 | this._store.unseenNtfnCount = 0; 181 | this._navigate('Notifications'); 182 | } 183 | 184 | goSettings() { 185 | this._navigate('Settings'); 186 | } 187 | 188 | goSettingsUnit() { 189 | this._navigate('SettingsUnit'); 190 | } 191 | 192 | goSettingsFiat() { 193 | this._navigate('SettingsFiat'); 194 | } 195 | 196 | goCLI() { 197 | this._navigate('CLI'); 198 | } 199 | 200 | goCreateChannel() { 201 | this._navigate('CreateChannel'); 202 | } 203 | 204 | goDeposit() { 205 | this._store.displayCopied = false; 206 | this._navigate('Deposit'); 207 | } 208 | } 209 | 210 | export default NavAction; 211 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lightning-app", 3 | "version": "0.5.9-alpha", 4 | "description": "Lightning Wallet Application", 5 | "author": "Lightning Labs, Inc", 6 | "homepage": "./", 7 | "license": "GPL-3.0", 8 | "private": true, 9 | "main": "public/electron.js", 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build", 13 | "test": "npm run -s test:lint && npm run -s test:unit && npm run -s test:integration", 14 | "test:lint": "eslint src public test stories .storybook \"mobile/*.js\"", 15 | "test:unit": "mocha --opts test/mocha.opts ./test/unit/", 16 | "test:integration": "mocha --opts test/mocha.opts ./test/integration/", 17 | "test:react": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject", 19 | "electron-dev": "concurrently \"BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron --enable-sandbox .\"", 20 | "electron-pack": "build --c.extraMetadata.main=build/electron.js", 21 | "electron-only": "electron --enable-sandbox .", 22 | "preelectron-pack": "npm run build", 23 | "prettier": "prettier --write --single-quote --trailing-comma es5 \"src/**/*.js\" public/electron.js", 24 | "postinstall": "electron-builder install-app-deps && npm run protobuf", 25 | "storybook": "start-storybook -p 9009 -s public", 26 | "build-storybook": "build-storybook -s public", 27 | "build-icon": "svgr --native --icon --filename-case kebab -d src/asset/icon src/asset/icon", 28 | "build-img": "svgr --native --filename-case kebab -d src/asset/img src/asset/img", 29 | "protobuf": "pbjs assets/rpc.proto -t static-module -w es6 -o assets/rpc.js" 30 | }, 31 | "dependencies": { 32 | "@grpc/proto-loader": "0.5.0", 33 | "electron-is-dev": "0.3.0", 34 | "electron-log": "2.2.14", 35 | "electron-updater": "4.0.6", 36 | "grpc": "1.20.2", 37 | "locale-currency": "0.0.2", 38 | "mobx": "^4.9.4", 39 | "mobx-react": "^5.4.3", 40 | "qr-image": "^3.2.0", 41 | "react": "^16.8.6", 42 | "react-art": "^16.8.6", 43 | "react-dom": "^16.8.6", 44 | "react-native-web": "^0.11.2", 45 | "svgs": "4.0.0" 46 | }, 47 | "devDependencies": { 48 | "@babel/cli": "^7.2.3", 49 | "@babel/core": "^7.6.0", 50 | "@babel/plugin-proposal-object-rest-spread": "^7.4.0", 51 | "@babel/plugin-transform-modules-commonjs": "^7.4.0", 52 | "@babel/register": "^7.4.0", 53 | "@storybook/addon-actions": "^5.0.6", 54 | "@storybook/addon-links": "^5.0.6", 55 | "@storybook/addons": "^5.0.6", 56 | "@storybook/react": "^5.0.6", 57 | "@svgr/cli": "3.1.0", 58 | "ajv": "^6.5.0", 59 | "babel-plugin-react-native-web": "^0.11.2", 60 | "concurrently": "^3.5.1", 61 | "electron": "4.1.5", 62 | "electron-builder": "20.39.0", 63 | "eslint-config-google": "^0.12.0", 64 | "eslint-config-prettier": "^4.1.0", 65 | "eslint-plugin-html": "^5.0.3", 66 | "eslint-plugin-prettier": "^3.0.1", 67 | "eslint-plugin-react": "^7.12.4", 68 | "isomorphic-fetch": "^2.2.1", 69 | "metro-react-native-babel-preset": "^0.53.1", 70 | "mocha": "^5.0.0", 71 | "nock": "^9.1.6", 72 | "prettier": "^1.11.1", 73 | "prop-types": "^15.6.2", 74 | "protobufjs": "^6.8.8", 75 | "react-scripts": "^3.0.0", 76 | "sinon": "^6.0.0", 77 | "unexpected": "^10.37.2", 78 | "unexpected-sinon": "^10.10.1", 79 | "wait-on": "^2.1.0", 80 | "webpack": "4.29.6" 81 | }, 82 | "browserslist": [ 83 | "electron 4.0" 84 | ], 85 | "build": { 86 | "appId": "engineering.lightning.lightning-app", 87 | "publish": { 88 | "provider": "github", 89 | "vPrefixedTagName": true, 90 | "publishAutoUpdate": true, 91 | "releaseType": "draft" 92 | }, 93 | "mac": { 94 | "category": "Network", 95 | "artifactName": "${productName}-darwin-x64v${version}.${ext}", 96 | "extraResources": "assets/bin/darwin/lnd", 97 | "icon": "assets/app-icon/desktop.icns", 98 | "target": [ 99 | "dmg", 100 | "zip" 101 | ] 102 | }, 103 | "linux": { 104 | "category": "Network", 105 | "artifactName": "${productName}-linux-${arch}v${version}.${ext}", 106 | "extraResources": "assets/bin/linux/lnd", 107 | "icon": "assets/app-icon/desktop.png", 108 | "target": "AppImage" 109 | }, 110 | "win": { 111 | "artifactName": "${productName}-win32-${arch}v${version}.${ext}", 112 | "extraResources": "assets/bin/win32/lnd.exe", 113 | "icon": "assets/app-icon/desktop.ico", 114 | "target": "nsis" 115 | }, 116 | "productName": "Lightning", 117 | "files": [ 118 | "build/**/*", 119 | "node_modules/**/*", 120 | "assets/*.proto", 121 | "src/config.js" 122 | ], 123 | "directories": { 124 | "buildResources": "assets" 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/action/grpc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview a low level action to proxy GRPC api calls to and from lnd 3 | * over an IPC api. This module should not be invokes directly 4 | * from the UI but rather used within other higher level actions. 5 | */ 6 | 7 | import { Duplex } from 'stream'; 8 | import * as log from './log'; 9 | 10 | class GrpcAction { 11 | constructor(store, ipc) { 12 | this._store = store; 13 | this._ipc = ipc; 14 | } 15 | 16 | // 17 | // WalletUnlocker grpc client 18 | // 19 | 20 | /** 21 | * The first GRPC api that is called to initialize the wallet unlocker. 22 | * Once `unlockerReady` is set to true on the store GRPC calls can be 23 | * made to the client. 24 | * @return {Promise} 25 | */ 26 | async initUnlocker() { 27 | await this._sendIpc('unlockInit', 'unlockReady'); 28 | log.info('GRPC unlockerReady'); 29 | this._store.unlockerReady = true; 30 | } 31 | 32 | /** 33 | * This GRPC api is called after the wallet is unlocked to close the grpc 34 | * client to lnd before the main lnd client is re-opened 35 | * @return {Promise} 36 | */ 37 | async closeUnlocker() { 38 | await this._sendIpc('unlockClose', 'unlockClosed'); 39 | log.info('GRPC unlockerClosed'); 40 | } 41 | 42 | /** 43 | * Wrapper function to execute calls to the wallet unlocker. 44 | * @param {string} method The unlocker GRPC api to call 45 | * @param {Object} body The payload passed to the api 46 | * @return {Promise} 47 | */ 48 | async sendUnlockerCommand(method, body) { 49 | return this._sendIpc('unlockRequest', 'unlockResponse', method, body); 50 | } 51 | 52 | // 53 | // Autopilot grpc client 54 | // 55 | 56 | /** 57 | * This is called to initialize the GRPC client to autopilot. Once `autopilotReady` 58 | * is set to true on the store GRPC calls can be made to the client. 59 | * @return {Promise} 60 | */ 61 | async initAutopilot() { 62 | await this._sendIpc('lndAtplInit', 'lndAtplReady'); 63 | this._store.autopilotReady = true; 64 | log.info('GRPC autopilotReady'); 65 | } 66 | 67 | /** 68 | * Wrapper function to execute calls to the autopilot grpc client. 69 | * @param {string} method The autopilot GRPC api to call 70 | * @param {Object} body The payload passed to the api 71 | * @return {Promise} 72 | */ 73 | async sendAutopilotCommand(method, body) { 74 | return this._sendIpc('lndAtplRequest', 'lndAtplResponse', method, body); 75 | } 76 | 77 | // 78 | // Lightning (lnd) grpc client 79 | // 80 | 81 | /** 82 | * This is called to initialize the main GRPC client to lnd. Once `lndReady` 83 | * is set to true on the store GRPC calls can be made to the client. 84 | * @return {Promise} 85 | */ 86 | async initLnd() { 87 | await this._sendIpc('lndInit', 'lndReady'); 88 | log.info('GRPC lndReady'); 89 | this._store.lndReady = true; 90 | } 91 | 92 | /** 93 | * Closes the main GRPC client to lnd. This should only be called upon exiting 94 | * the application as api calls need to be throughout the lifetime of the app. 95 | * @return {Promise} 96 | */ 97 | async closeLnd() { 98 | await this._sendIpc('lndClose', 'lndClosed'); 99 | log.info('GRPC lndClosed'); 100 | } 101 | 102 | /** 103 | * This is called to restart the lnd process, after closing the main gRPC 104 | * client that's connected to it. 105 | * @return {Promise} 106 | */ 107 | async restartLnd() { 108 | await this.closeLnd(); 109 | let restartError = await this._sendIpc( 110 | 'lnd-restart-process', 111 | 'lnd-restart-error' 112 | ); 113 | if (restartError) { 114 | throw new Error(`Failed to restart lnd: ${restartError}`); 115 | } 116 | } 117 | 118 | /** 119 | * Wrapper function to execute calls to the lnd grpc client. 120 | * @param {string} method The lnd GRPC api to call 121 | * @param {Object} body The payload passed to the api 122 | * @return {Promise} 123 | */ 124 | sendCommand(method, body) { 125 | return this._sendIpc('lndRequest', 'lndResponse', method, body); 126 | } 127 | 128 | /** 129 | * Wrapper function to execute GRPC streaming api calls to lnd. This function 130 | * proxies data to and from lnd using a duplex stream which is returned. 131 | * @param {string} method The lnd GRPC api to call 132 | * @param {Object} body The payload passed to the api 133 | * @return {Duplex} The duplex stream object instance 134 | */ 135 | sendStreamCommand(method, body) { 136 | const self = this; 137 | const stream = new Duplex({ 138 | write(data) { 139 | data = JSON.parse(data.toString('utf8')); 140 | self._ipc.send('lndStreamWrite', null, { method, data }); 141 | }, 142 | read() {}, 143 | }); 144 | this._ipc.listen(`lndStreamEvent_${method}`, (e, arg) => { 145 | stream.emit(arg.event, arg.data || arg.err); 146 | }); 147 | this._ipc.send('lndStreamRequest', null, { method, body }); 148 | return stream; 149 | } 150 | 151 | // 152 | // Helper functions 153 | // 154 | 155 | async _sendIpc(event, listen, method, body) { 156 | try { 157 | listen = method ? `${listen}_${method}` : listen; 158 | return await this._ipc.send(event, listen, { method, body }); 159 | } catch (err) { 160 | throw new Error(err.details); 161 | } 162 | } 163 | } 164 | 165 | export default GrpcAction; 166 | -------------------------------------------------------------------------------- /src/action/index-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview this is the main action module for mobile where all actions 3 | * are initilized by injecting dependencies and then triggering startup actions 4 | * when certain flags on the global store are set to true. 5 | */ 6 | 7 | import { when } from 'mobx'; 8 | import { 9 | Alert, 10 | Linking, 11 | Platform, 12 | Clipboard, 13 | AsyncStorage, 14 | NativeModules, 15 | ActionSheetIOS, 16 | NativeEventEmitter, 17 | PermissionsAndroid, 18 | } from 'react-native'; 19 | import * as Random from 'expo-random'; 20 | import * as LocalAuthentication from 'expo-local-authentication'; 21 | import * as RNKeychain from 'react-native-keychain'; 22 | import RNFS from 'react-native-fs'; 23 | import RNShare from 'react-native-share'; 24 | import RNDeviceInfo from 'react-native-device-info'; 25 | import RNiCloudStorage from 'react-native-icloudstore'; 26 | import { NavigationActions, StackActions } from 'react-navigation'; 27 | import { nap } from '../helper'; 28 | import store from '../store'; 29 | import { LND_NETWORK } from '../config'; 30 | import AppStorage from './app-storage'; 31 | import IpcAction from './ipc-mobile'; 32 | import GrpcAction from './grpc-mobile'; 33 | import NavAction from './nav-mobile'; 34 | import WalletAction from './wallet'; 35 | import LogAction from './log'; 36 | import FileAction from './file-mobile'; 37 | import KeychainAction from './keychain-mobile'; 38 | import BackupAction from './backup-mobile'; 39 | import InfoAction from './info'; 40 | import NotificationAction from './notification'; 41 | import ChannelAction from './channel'; 42 | import TransactionAction from './transaction'; 43 | import PaymentAction from './payment'; 44 | import InvoiceAction from './invoice'; 45 | import SettingAction from './setting'; 46 | import AuthAction from './auth-mobile'; 47 | import AtplAction from './autopilot'; 48 | 49 | // 50 | // Inject dependencies 51 | // 52 | 53 | store.network = LND_NETWORK; // set to read SCB file for restore 54 | store.init(); // initialize computed values 55 | 56 | export const db = new AppStorage(store, AsyncStorage); 57 | export const grpc = new GrpcAction(store, NativeModules, NativeEventEmitter); 58 | export const keychain = new KeychainAction(RNKeychain); 59 | export const ipc = new IpcAction(grpc); 60 | export const file = new FileAction(store, RNFS, RNShare); 61 | export const log = new LogAction(store, ipc, false); 62 | export const nav = new NavAction(store, NavigationActions, StackActions); 63 | export const notify = new NotificationAction(store, nav); 64 | export const backup = new BackupAction( 65 | grpc, 66 | file, 67 | Platform, 68 | RNDeviceInfo, 69 | PermissionsAndroid, 70 | RNiCloudStorage 71 | ); 72 | export const wallet = new WalletAction( 73 | store, 74 | grpc, 75 | db, 76 | nav, 77 | notify, 78 | file, 79 | backup 80 | ); 81 | export const info = new InfoAction(store, grpc, nav, notify); 82 | export const transaction = new TransactionAction(store, grpc, nav, notify); 83 | export const channel = new ChannelAction(store, grpc, nav, notify); 84 | export const invoice = new InvoiceAction(store, grpc, nav, notify, Clipboard); 85 | export const payment = new PaymentAction(store, grpc, nav, notify, Clipboard); 86 | export const setting = new SettingAction(store, wallet, db, ipc); 87 | export const auth = new AuthAction( 88 | store, 89 | wallet, 90 | nav, 91 | Random, 92 | keychain, 93 | LocalAuthentication, 94 | Alert, 95 | ActionSheetIOS, 96 | Platform 97 | ); 98 | export const autopilot = new AtplAction(store, grpc, db, notify); 99 | 100 | payment.listenForUrlMobile(Linking); // enable incoming url handler 101 | 102 | // 103 | // Init actions 104 | // 105 | 106 | db.restore(); // read user settings from disk 107 | 108 | /** 109 | * Triggered after user settings are restored from disk and the 110 | * navigator is ready. 111 | */ 112 | when(() => store.loaded && store.navReady, () => grpc.initUnlocker()); 113 | 114 | /** 115 | * Triggered after the wallet unlocker grpc client is initialized. 116 | */ 117 | when(() => store.unlockerReady, () => wallet.init()); 118 | 119 | /** 120 | * Triggered after the user's password has unlocked the wallet 121 | * or a user's password has been successfully reset. 122 | */ 123 | when( 124 | () => store.walletUnlocked, 125 | async () => { 126 | await grpc.initLnd(); 127 | await grpc.initAutopilot(); 128 | } 129 | ); 130 | 131 | /** 132 | * Triggered once the main lnd grpc client is initialized. This is when 133 | * the user can really begin to interact with the application and calls 134 | * to and from lnd can be done. The display the current state of the 135 | * lnd node all balances, channels and transactions are fetched. 136 | */ 137 | when( 138 | () => store.lndReady, 139 | () => { 140 | wallet.pollBalances(); 141 | wallet.pollExchangeRate(); 142 | channel.pollChannels(); 143 | transaction.update(); 144 | transaction.subscribeTransactions(); 145 | transaction.subscribeInvoices(); 146 | info.pollInfo(); 147 | } 148 | ); 149 | 150 | /** 151 | * Keep the Static Channel Backup (SCB) synced to external storage once 152 | * lnd ready and has set the `network` attribute upon polling `getInfo`. 153 | */ 154 | when( 155 | () => store.network && store.syncedToChain, 156 | async () => { 157 | backup.pushChannelBackup(); 158 | backup.subscribeChannelBackups(); 159 | } 160 | ); 161 | 162 | /** 163 | * Initialize autopilot after syncing is finished and the grpc client 164 | * is ready 165 | */ 166 | when( 167 | () => store.syncedToChain && store.network && store.autopilotReady, 168 | async () => { 169 | await nap(); 170 | autopilot.init(); 171 | } 172 | ); 173 | -------------------------------------------------------------------------------- /src/action/transaction.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to set transactions state within the app and to 3 | * call the corresponding GRPC apis for listing transactions. 4 | */ 5 | 6 | import * as log from './log'; 7 | import { parseDate, toHex } from '../helper'; 8 | 9 | class TransactionAction { 10 | constructor(store, grpc, nav, notification) { 11 | this._store = store; 12 | this._grpc = grpc; 13 | this._nav = nav; 14 | this._notification = notification; 15 | } 16 | 17 | /** 18 | * Initiate the transaction list view by navigating to the view and updating 19 | * the app's transaction state by calling all necessary grpc apis. 20 | * @return {undefined} 21 | */ 22 | init() { 23 | this._nav.goTransactions(); 24 | this.update(); 25 | } 26 | 27 | /** 28 | * Select a transaction item from the transaction list view and then navigate 29 | * to the detail view to list transaction parameters. 30 | * @param {Object} options.item The selected transaction object 31 | * @return {Promise} 32 | */ 33 | async select({ item }) { 34 | this._store.selectedTransaction = item; 35 | if (item.paymentRequest) { 36 | item.memo = await this.decodeMemo({ payReq: item.paymentRequest }); 37 | } 38 | this._nav.goTransactionDetail(); 39 | this.update(); 40 | } 41 | 42 | /** 43 | * Update the on-chain transactions, invoice, and lighting payments in the 44 | * app state by querying all required grpc apis. 45 | * @return {Promise} 46 | */ 47 | async update() { 48 | await Promise.all([ 49 | this.getTransactions(), 50 | this.getInvoices(), 51 | this.getPayments(), 52 | ]); 53 | } 54 | 55 | /** 56 | * List the on-chain transactions by calling the respective grpc api and updating 57 | * the transactions array in the global store. 58 | * @return {Promise} 59 | */ 60 | async getTransactions() { 61 | try { 62 | const { transactions } = await this._grpc.sendCommand('getTransactions'); 63 | this._store.transactions = transactions.map(transaction => ({ 64 | id: transaction.txHash, 65 | type: 'bitcoin', 66 | amount: transaction.amount, 67 | fee: transaction.totalFees, 68 | confirmations: transaction.numConfirmations, 69 | status: transaction.numConfirmations < 3 ? 'unconfirmed' : 'confirmed', 70 | date: parseDate(transaction.timeStamp), 71 | })); 72 | } catch (err) { 73 | log.error('Listing transactions failed', err); 74 | } 75 | } 76 | 77 | /** 78 | * List the lightning invoices by calling the respective grpc api and updating 79 | * the invoices array in the global store. 80 | * @return {Promise} 81 | */ 82 | async getInvoices() { 83 | try { 84 | const { invoices } = await this._grpc.sendCommand('listInvoices'); 85 | this._store.invoices = invoices.map(invoice => ({ 86 | id: toHex(invoice.rHash), 87 | type: 'lightning', 88 | amount: invoice.value, 89 | status: invoice.settled ? 'complete' : 'in-progress', 90 | date: parseDate(invoice.creationDate), 91 | memo: invoice.memo, 92 | })); 93 | } catch (err) { 94 | log.error('Listing invoices failed', err); 95 | } 96 | } 97 | 98 | /** 99 | * List the lightning payments by calling the respective grpc api and updating 100 | * the payments array in the global store. 101 | * @return {Promise} 102 | */ 103 | async getPayments() { 104 | try { 105 | const { payments } = await this._grpc.sendCommand('listPayments'); 106 | this._store.payments = payments.map(payment => ({ 107 | id: payment.paymentHash, 108 | type: 'lightning', 109 | amount: -1 * payment.value, 110 | fee: payment.fee, 111 | status: 'complete', 112 | date: parseDate(payment.creationDate), 113 | preimage: payment.paymentPreimage, 114 | paymentRequest: payment.paymentRequest, 115 | })); 116 | } catch (err) { 117 | log.error('Listing payments failed', err); 118 | } 119 | } 120 | 121 | /** 122 | * Attempt to decode a lightning payment request using the lnd grpc api. 123 | * @param {string} options.payReq The input to be validated 124 | * @return {Promise} If the input is a valid invoice 125 | */ 126 | async decodeMemo({ payReq }) { 127 | try { 128 | const { description } = await this._grpc.sendCommand('decodePayReq', { 129 | payReq, 130 | }); 131 | return description; 132 | } catch (err) { 133 | log.info(`Decoding payment request failed: ${err.message}`); 134 | } 135 | } 136 | 137 | /** 138 | * Subscribe to incoming on-chain transactions using the grpc streaming api. 139 | * @return {Promise} 140 | */ 141 | async subscribeTransactions() { 142 | const stream = this._grpc.sendStreamCommand('subscribeTransactions'); 143 | await new Promise((resolve, reject) => { 144 | stream.on('data', () => this.update()); 145 | stream.on('end', resolve); 146 | stream.on('error', reject); 147 | stream.on('status', status => log.info(`Transactions update: ${status}`)); 148 | }); 149 | } 150 | 151 | /** 152 | * Subscribe to incoming invoice payments using the grpc streaming api. 153 | * @return {Promise} 154 | */ 155 | async subscribeInvoices() { 156 | const stream = this._grpc.sendStreamCommand('subscribeInvoices'); 157 | await new Promise((resolve, reject) => { 158 | stream.on('data', invoice => this._receiveInvoice(invoice)); 159 | stream.on('end', resolve); 160 | stream.on('error', reject); 161 | stream.on('status', status => log.info(`Invoices update: ${status}`)); 162 | }); 163 | } 164 | 165 | // 166 | // Helper functions 167 | // 168 | 169 | async _receiveInvoice(invoice) { 170 | await this.update(); 171 | if (!invoice.settled) return; 172 | const { computedTransactions, unitLabel } = this._store; 173 | let inv = computedTransactions.find(tx => tx.id === toHex(invoice.rHash)); 174 | this._notification.display({ 175 | type: 'success', 176 | msg: `Invoice success: received ${inv.amountLabel} ${unitLabel || ''}`, 177 | handler: () => this.select({ item: inv }), 178 | handlerLbl: 'View details', 179 | }); 180 | } 181 | } 182 | 183 | export default TransactionAction; 184 | -------------------------------------------------------------------------------- /src/action/grpc-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview a low level action to proxy GRPC api calls to and from lnd 3 | * mobile via a native module. This module should not be invokes directly 4 | * from the UI but rather used within other higher level actions. 5 | */ 6 | 7 | import { Duplex } from 'stream'; 8 | import base64 from 'base64-js'; 9 | 10 | import { lnrpc } from '../../assets/rpc'; 11 | import * as log from './log'; 12 | import { toCaps } from '../helper'; 13 | 14 | class GrpcAction { 15 | constructor(store, NativeModules, NativeEventEmitter) { 16 | this._store = store; 17 | this._lnd = NativeModules.LndReactModule; 18 | this._lndEvent = new NativeEventEmitter(this._lnd); 19 | this._streamCounter = 0; 20 | } 21 | 22 | // 23 | // WalletUnlocker grpc client 24 | // 25 | 26 | /** 27 | * The first GRPC api that is called to initialize the wallet unlocker. 28 | * Once `unlockerReady` is set to true on the store GRPC calls can be 29 | * made to the client. 30 | * @return {Promise} 31 | */ 32 | async initUnlocker() { 33 | await this._lnd.start(); 34 | log.info('GRPC unlockerReady'); 35 | this._store.unlockerReady = true; 36 | } 37 | 38 | /** 39 | * This GRPC api is called after the wallet is unlocked to close the grpc 40 | * client to lnd before the main lnd client is re-opened 41 | * @return {Promise} 42 | */ 43 | async closeUnlocker() { 44 | // TODO: restart is not required on mobile 45 | // await this._lnd.closeUnlocker(); 46 | log.info('GRPC unlockerClosed'); 47 | } 48 | 49 | /** 50 | * Wrapper function to execute calls to the wallet unlocker. 51 | * @param {string} method The unlocker GRPC api to call 52 | * @param {Object} body The payload passed to the api 53 | * @return {Promise} 54 | */ 55 | async sendUnlockerCommand(method, body) { 56 | return this._lnrpcRequest(method, body); 57 | } 58 | 59 | // 60 | // Autopilot grpc client 61 | // 62 | 63 | /** 64 | * This is called to initialize the GRPC client to autopilot. Once `autopilotReady` 65 | * is set to true on the store GRPC calls can be made to the client. 66 | * @return {Promise} 67 | */ 68 | async initAutopilot() { 69 | this._store.autopilotReady = true; 70 | log.info('GRPC autopilotReady'); 71 | } 72 | 73 | /** 74 | * Wrapper function to execute calls to the autopilot grpc client. 75 | * @param {string} method The autopilot GRPC api to call 76 | * @param {Object} body The payload passed to the api 77 | * @return {Promise} 78 | */ 79 | async sendAutopilotCommand(method, body) { 80 | return this._lnrpcRequest(method, body); 81 | } 82 | 83 | // 84 | // Lightning (lnd) grpc client 85 | // 86 | 87 | /** 88 | * This is called to initialize the main GRPC client to lnd. Once `lndReady` 89 | * is set to true on the store GRPC calls can be made to the client. 90 | * @return {Promise} 91 | */ 92 | async initLnd() { 93 | // TODO: restart is not required on mobile 94 | // await this._lnd.start(); 95 | log.info('GRPC lndReady'); 96 | this._store.lndReady = true; 97 | } 98 | 99 | /** 100 | * Closes the main GRPC client to lnd. This should only be called upon exiting 101 | * the application as api calls need to be throughout the lifetime of the app. 102 | * @return {Promise} 103 | */ 104 | async closeLnd() { 105 | // TODO: add api on mobile 106 | // await this._lnd.close(); 107 | log.info('GRPC lndClosed'); 108 | } 109 | 110 | /** 111 | * This is called to restart the lnd process, after closing the main gRPC 112 | * client that's connected to it. 113 | * @return {Promise} 114 | */ 115 | async restartLnd() { 116 | await this.closeLnd(); 117 | // TODO: handle restart in native module 118 | } 119 | 120 | /** 121 | * Wrapper function to execute calls to the lnd grpc client. 122 | * @param {string} method The lnd GRPC api to call 123 | * @param {Object} body The payload passed to the api 124 | * @return {Promise} 125 | */ 126 | sendCommand(method, body) { 127 | return this._lnrpcRequest(method, body); 128 | } 129 | 130 | /** 131 | * Wrapper function to execute GRPC streaming api calls to lnd. This function 132 | * proxies data to and from lnd using a duplex stream which is returned. 133 | * @param {string} method The lnd GRPC api to call 134 | * @param {Object} body The payload passed to the api 135 | * @return {Duplex} The duplex stream object instance 136 | */ 137 | sendStreamCommand(method, body) { 138 | method = toCaps(method); 139 | const self = this; 140 | const streamId = self._generateStreamId(); 141 | const stream = new Duplex({ 142 | write(data) { 143 | data = JSON.parse(data.toString('utf8')); 144 | const req = self._serializeRequest(method, data); 145 | self._lnd.sendStreamWrite(streamId, req); 146 | }, 147 | read() {}, 148 | }); 149 | self._lndEvent.addListener('streamEvent', res => { 150 | if (res.streamId !== streamId) { 151 | return; 152 | } else if (res.event === 'data') { 153 | stream.emit('data', self._deserializeResponse(method, res.data)); 154 | } else { 155 | stream.emit(res.event, res.error || res.data); 156 | } 157 | }); 158 | const req = self._serializeRequest(method, body); 159 | self._lnd.sendStreamCommand(method, streamId, req); 160 | return stream; 161 | } 162 | 163 | // 164 | // Helper functions 165 | // 166 | 167 | async _lnrpcRequest(method, body) { 168 | try { 169 | method = toCaps(method); 170 | const req = this._serializeRequest(method, body); 171 | const response = await this._lnd.sendCommand(method, req); 172 | return this._deserializeResponse(method, response.data); 173 | } catch (err) { 174 | if (typeof err === 'string') { 175 | throw new Error(err); 176 | } else { 177 | throw err; 178 | } 179 | } 180 | } 181 | 182 | _serializeRequest(method, body = {}) { 183 | const req = lnrpc[this._getRequestName(method)]; 184 | const message = req.create(body); 185 | const buffer = req.encode(message).finish(); 186 | return base64.fromByteArray(buffer); 187 | } 188 | 189 | _deserializeResponse(method, response) { 190 | const res = lnrpc[this._getResponseName(method)]; 191 | const buffer = base64.toByteArray(response); 192 | return res.decode(buffer); 193 | } 194 | 195 | _serializeResponse(method, body = {}) { 196 | const res = lnrpc[this._getResponseName(method)]; 197 | const message = res.create(body); 198 | const buffer = res.encode(message).finish(); 199 | return base64.fromByteArray(buffer); 200 | } 201 | 202 | _generateStreamId() { 203 | this._streamCounter = this._streamCounter + 1; 204 | return String(this._streamCounter); 205 | } 206 | 207 | _getRequestName(method) { 208 | const map = { 209 | AddInvoice: 'Invoice', 210 | DecodePayReq: 'PayReqString', 211 | ListInvoices: 'ListInvoiceRequest', 212 | SendPayment: 'SendRequest', 213 | SubscribeTransactions: 'GetTransactionsRequest', 214 | SubscribeInvoices: 'InvoiceSubscription', 215 | SubscribeChannelBackups: 'ChannelBackupSubscription', 216 | StopDaemon: 'StopRequest', 217 | }; 218 | return map[method] || `${method}Request`; 219 | } 220 | 221 | _getResponseName(method) { 222 | const map = { 223 | DecodePayReq: 'PayReq', 224 | GetTransactions: 'TransactionDetails', 225 | ListInvoices: 'ListInvoiceResponse', 226 | SendPayment: 'SendResponse', 227 | OpenChannel: 'OpenStatusUpdate', 228 | CloseChannel: 'CloseStatusUpdate', 229 | SubscribeTransactions: 'Transaction', 230 | SubscribeInvoices: 'Invoice', 231 | SubscribeChannelBackups: 'ChanBackupSnapshot', 232 | StopDaemon: 'StopResponse', 233 | }; 234 | return map[method] || `${method}Response`; 235 | } 236 | } 237 | 238 | export default GrpcAction; 239 | -------------------------------------------------------------------------------- /src/action/auth-mobile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview action to handle mobile specific authentication 3 | * using PINs, TouchID, and KeyStore storage. 4 | */ 5 | 6 | import { PIN_LENGTH } from '../config'; 7 | 8 | const PIN = 'DevicePin'; 9 | const PASS = 'WalletPassword'; 10 | 11 | class AuthAction { 12 | constructor( 13 | store, 14 | wallet, 15 | nav, 16 | Random, 17 | keychain, 18 | Fingerprint, 19 | Alert, 20 | ActionSheetIOS, 21 | Platform 22 | ) { 23 | this._store = store; 24 | this._wallet = wallet; 25 | this._nav = nav; 26 | this._Random = Random; 27 | this._keychain = keychain; 28 | this._Fingerprint = Fingerprint; 29 | this._Alert = Alert; 30 | this._ActionSheetIOS = ActionSheetIOS; 31 | this._Platform = Platform; 32 | } 33 | 34 | // 35 | // PIN actions 36 | // 37 | 38 | /** 39 | * Initialize the set pin view by resetting input values 40 | * and then navigating to the view. 41 | * @return {undefined} 42 | */ 43 | initSetPin() { 44 | this._store.auth.newPin = ''; 45 | this._store.auth.pinVerify = ''; 46 | this._nav.goSetPassword(); 47 | } 48 | 49 | /** 50 | * Initialize the pin view by resetting input values 51 | * and then navigating to the view. 52 | * @return {undefined} 53 | */ 54 | initPin() { 55 | this._store.auth.pin = ''; 56 | this._nav.goPassword(); 57 | } 58 | 59 | /** 60 | * Initialize the reset pin flow by resetting input values 61 | * and then navigating to the view. 62 | * @return {undefined} 63 | */ 64 | initResetPin() { 65 | this._store.auth.resetPinCurrent = ''; 66 | this._store.auth.resetPinNew = ''; 67 | this._store.auth.resetPinVerify = ''; 68 | this._nav.goResetPasswordCurrent(); 69 | } 70 | 71 | /** 72 | * Initialize the reset new pin flow by resetting new values 73 | * and then navigating to the new pin view. 74 | * @return {undefined} 75 | */ 76 | initResetPinNew() { 77 | this._store.auth.resetPinNew = ''; 78 | this._store.auth.resetPinVerify = ''; 79 | this._nav.goResetPasswordNew(); 80 | } 81 | 82 | /** 83 | * Append a digit input to the pin parameter. 84 | * @param {string} options.digit The digit to append to the pin 85 | * @param {string} options.param The pin parameter name 86 | * @return {undefined} 87 | */ 88 | pushPinDigit({ digit, param }) { 89 | const { auth } = this._store; 90 | if (auth[param].length < PIN_LENGTH) { 91 | auth[param] += digit; 92 | } 93 | if (auth[param].length < PIN_LENGTH) { 94 | return; 95 | } 96 | if (param === 'newPin') { 97 | this._nav.goSetPasswordConfirm(); 98 | } else if (param === 'pinVerify') { 99 | this.checkNewPin(); 100 | } else if (param === 'pin') { 101 | this.checkPin(); 102 | } else if (param === 'resetPinCurrent') { 103 | this._nav.goResetPasswordNew(); 104 | } else if (param === 'resetPinNew') { 105 | this._nav.goResetPasswordConfirm(); 106 | } else if (param === 'resetPinVerify') { 107 | this.checkResetPin(); 108 | } 109 | } 110 | 111 | /** 112 | * Remove the last digit from the pin parameter. 113 | * @param {string} options.param The pin parameter name 114 | * @return {undefined} 115 | */ 116 | popPinDigit({ param }) { 117 | const { auth } = this._store; 118 | if (auth[param]) { 119 | auth[param] = auth[param].slice(0, -1); 120 | } else if (param === 'pinVerify') { 121 | this.initSetPin(); 122 | } else if (param === 'resetPinCurrent') { 123 | this._nav.goSettings(); 124 | } else if (param === 'resetPinNew') { 125 | this.initResetPin(); 126 | } else if (param === 'resetPinVerify') { 127 | this.initResetPinNew(); 128 | } 129 | } 130 | 131 | /** 132 | * Check the PIN that was chosen by the user was entered 133 | * correctly twice to make sure that there was no typo. 134 | * If everything is ok, store the pin in the keystore and 135 | * unlock the wallet. 136 | * @return {Promise} 137 | */ 138 | async checkNewPin() { 139 | const { newPin, pinVerify } = this._store.auth; 140 | if (newPin.length !== PIN_LENGTH || newPin !== pinVerify) { 141 | this._alert("PINs don't match", () => this.initSetPin()); 142 | return; 143 | } 144 | await this._keychain.setItem(PIN, newPin); 145 | await this._generateWalletPassword(); 146 | } 147 | 148 | /** 149 | * Check the PIN that was entered by the user in the unlock 150 | * screen matches the pin stored in the keystore and unlock 151 | * the wallet. 152 | * @return {Promise} 153 | */ 154 | async checkPin() { 155 | const { pin } = this._store.auth; 156 | const storedPin = await this._keychain.getItem(PIN); 157 | if (pin !== storedPin) { 158 | this._alert('Incorrect PIN', () => this.initPin()); 159 | return; 160 | } 161 | await this._unlockWallet(); 162 | } 163 | 164 | /** 165 | * Check that the pin that was chosen by the user doesn't match 166 | * their current pin, and that it was entered correctly twice. 167 | * If everything is ok, store the pin in the keystore and redirect 168 | * to home. 169 | * @return {undefined} 170 | */ 171 | async checkResetPin() { 172 | const { resetPinCurrent, resetPinNew, resetPinVerify } = this._store.auth; 173 | const storedPin = await this._keychain.getItem(PIN); 174 | if (resetPinCurrent !== storedPin) { 175 | this._alert('Incorrect PIN', () => this.initResetPin()); 176 | return; 177 | } else if (resetPinCurrent === resetPinNew) { 178 | this._alert('New PIN must not match old PIN', () => this.initResetPin()); 179 | return; 180 | } else if ( 181 | resetPinNew.length !== PIN_LENGTH || 182 | resetPinNew !== resetPinVerify 183 | ) { 184 | this._alert("PINs don't match", () => this.initResetPin()); 185 | return; 186 | } 187 | await this._keychain.setItem(PIN, resetPinNew); 188 | this._nav.goResetPasswordSaved(); 189 | } 190 | 191 | // 192 | // TouchID & KeyStore Authentication 193 | // 194 | 195 | /** 196 | * Try authenticating the user using either via TouchID/FaceID on iOS 197 | * or a fingerprint reader on Android. 198 | * @return {Promise} 199 | */ 200 | async tryFingerprint() { 201 | const hasHardware = await this._Fingerprint.hasHardwareAsync(); 202 | const isEnrolled = await this._Fingerprint.isEnrolledAsync(); 203 | if (!hasHardware || !isEnrolled) { 204 | return; 205 | } 206 | const msg = 'Unlock your Wallet'; 207 | const { success } = await this._Fingerprint.authenticateAsync(msg); 208 | if (!success) { 209 | return; 210 | } 211 | await this._unlockWallet(); 212 | } 213 | 214 | /** 215 | * A new wallet password is generated and stored in the keystore 216 | * during device setup. This password is not intended to be displayed 217 | * to the user but is unlocked at the application layer via TouchID 218 | * or PIN (which is stored in the keystore). 219 | * @return {Promise} 220 | */ 221 | async _generateWalletPassword() { 222 | const newPass = await this._secureRandomPassword(); 223 | await this._keychain.setItem(PASS, newPass); 224 | this._store.wallet.newPassword = newPass; 225 | this._store.wallet.passwordVerify = newPass; 226 | await this._wallet.checkNewPassword(); 227 | } 228 | 229 | /** 230 | * Unlock the wallet using a randomly generated password that is 231 | * stored in the keystore. This password is not intended to be displayed 232 | * to the user but rather unlocked at the application layer. 233 | * @return {Promise} 234 | */ 235 | async _unlockWallet() { 236 | const storedPass = await this._keychain.getItem(PASS); 237 | this._store.wallet.password = storedPass; 238 | await this._wallet.checkPassword(); 239 | } 240 | 241 | _alert(title, callback) { 242 | this._Alert.alert(title, '', [{ text: 'TRY AGAIN', onPress: callback }]); 243 | } 244 | 245 | /** 246 | * Generate a random hex encoded 256 bit entropy wallet password. 247 | * @return {Promise} A hex string containing some random bytes 248 | */ 249 | async _secureRandomPassword() { 250 | const bytes = await this._Random.getRandomBytesAsync(32); 251 | return Buffer.from(bytes.buffer).toString('hex'); 252 | } 253 | 254 | // 255 | // Help / Restore actions 256 | // 257 | 258 | askForHelp() { 259 | const message = 260 | "If you have forgotten your PIN, or you're locked out of your wallet, you can reset your PIN with your Recovery Phrase."; 261 | const action = 'Recover My Wallet'; 262 | const cancel = 'Cancel'; 263 | if (this._Platform.OS === 'ios') { 264 | this._ActionSheetIOS.showActionSheetWithOptions( 265 | { 266 | message, 267 | options: [cancel, action], 268 | cancelButtonIndex: 0, 269 | destructiveButtonIndex: 1, 270 | }, 271 | i => i === 1 && this._initRestoreWallet() 272 | ); 273 | } else { 274 | this._Alert.alert(null, message, [ 275 | { 276 | text: action, 277 | onPress: () => this._initRestoreWallet(), 278 | }, 279 | { text: cancel, style: 'cancel' }, 280 | ]); 281 | } 282 | } 283 | 284 | _initRestoreWallet() { 285 | this._store.settings.restoring = true; 286 | this._wallet.initRestoreWallet(); 287 | } 288 | } 289 | 290 | export default AuthAction; 291 | -------------------------------------------------------------------------------- /src/action/payment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to set payment state within the app and to 3 | * call the corresponding GRPC apis for payment management. 4 | */ 5 | 6 | import { 7 | PREFIX_REGEX, 8 | PAYMENT_TIMEOUT, 9 | POLL_STORE_TIMEOUT, 10 | LOW_TARGET_CONF, 11 | MED_TARGET_CONF, 12 | HIGH_TARGET_CONF, 13 | } from '../config'; 14 | import { toSatoshis, toAmount, isLnUri, isAddress, nap } from '../helper'; 15 | import * as log from './log'; 16 | 17 | class PaymentAction { 18 | constructor(store, grpc, nav, notification, clipboard) { 19 | this._store = store; 20 | this._grpc = grpc; 21 | this._nav = nav; 22 | this._notification = notification; 23 | this._clipboard = clipboard; 24 | } 25 | 26 | /** 27 | * Set the listener for IPC from the main electron process to 28 | * handle incoming URIs containing lightning invoices. 29 | * @param {Object} ipcRenderer Electron's IPC api for the rendering process 30 | * @return {undefined} 31 | */ 32 | listenForUrl(ipc) { 33 | ipc.listen('open-url', (event, url) => this._openUrl(url)); 34 | } 35 | 36 | /** 37 | * Set the listener for the mobile app to handle incoming URIs 38 | * containing lightning invoices. 39 | * @param {Object} Linking The expo api to handle incoming uris 40 | * @return {undefined} 41 | */ 42 | async listenForUrlMobile(Linking) { 43 | Linking.addEventListener('url', ({ url }) => this._openUrl(url)); 44 | const url = await Linking.getInitialURL(); 45 | if (!url) { 46 | return; 47 | } 48 | while (!this._store.navReady) { 49 | await nap(POLL_STORE_TIMEOUT); 50 | } 51 | while (!this._store.syncedToChain) { 52 | await nap(POLL_STORE_TIMEOUT); 53 | } 54 | await this._openUrl(url); 55 | } 56 | 57 | async _openUrl(url) { 58 | log.info('open-url', url); 59 | if (!isLnUri(url)) { 60 | return; 61 | } 62 | while (!this._store.lndReady) { 63 | await nap(POLL_STORE_TIMEOUT); 64 | } 65 | this.init(); 66 | this.setAddress({ address: url }); 67 | this.checkType(); 68 | } 69 | 70 | /** 71 | * Read data from the QR code scanner, set it as the address and 72 | * check which type of invoice it is. 73 | * @param {string} options.data The data containing the scanned invoice 74 | * @return {undefined} 75 | */ 76 | readQRCode({ data }) { 77 | if (!data) { 78 | return; 79 | } 80 | this.setAddress({ address: data }); 81 | this.checkType(); 82 | } 83 | 84 | /** 85 | * Toggle between address input field and the QR code scanner. 86 | * @return {undefined} 87 | */ 88 | toggleScanner() { 89 | this._store.payment.useScanner = !this._store.payment.useScanner; 90 | } 91 | 92 | /** 93 | * Paste the contents of the clipboard into the address input 94 | * and then check which type of invoice it is. 95 | * @return {Promise} 96 | */ 97 | async pasteAddress() { 98 | this.setAddress({ address: await this._clipboard.getString() }); 99 | await this.checkType(); 100 | } 101 | 102 | /** 103 | * Initialize the payment view by resetting input values 104 | * and then navigating to the view. 105 | * @return {undefined} 106 | */ 107 | init() { 108 | this._store.payment.address = ''; 109 | this._store.payment.amount = ''; 110 | this._store.payment.targetConf = MED_TARGET_CONF; 111 | this._store.payment.fee = ''; 112 | this._store.payment.note = ''; 113 | this._store.payment.useScanner = false; 114 | this._store.payment.sendAll = false; 115 | this._nav.goPay(); 116 | } 117 | 118 | /** 119 | * Set the address input for the payment view. This can either be 120 | * an on-chain bitcoin addres or an encoded lightning invoice. 121 | * @param {string} options.address The payment address 122 | */ 123 | setAddress({ address }) { 124 | this._store.payment.address = address.replace(PREFIX_REGEX, ''); 125 | } 126 | 127 | /** 128 | * Set the amount input for the payment view. This amount 129 | * is either in btc or fiat depending on user settings. 130 | * @param {string} options.amount The string formatted number 131 | */ 132 | setAmount({ amount }) { 133 | this._store.payment.amount = amount; 134 | this._store.payment.sendAll = false; 135 | } 136 | 137 | /** 138 | * Set the payment amount to the max amount that can be sent. This 139 | * is useful for people to move their coins off of the app. 140 | * @return {Promise} 141 | */ 142 | async toggleMax() { 143 | const { payment, balanceSatoshis, settings } = this._store; 144 | if (payment.sendAll) { 145 | return this.setAmount({ amount: '0' }); 146 | } 147 | let amtSat = Math.floor(0.8 * balanceSatoshis); 148 | payment.amount = toAmount(amtSat, settings, 2); 149 | await this.estimateFee(); 150 | amtSat = balanceSatoshis - toSatoshis(payment.fee, settings); 151 | payment.amount = toAmount(amtSat, settings, 2); 152 | payment.sendAll = true; 153 | } 154 | 155 | /** 156 | * Check if the address input provided by the user is either an on-chain 157 | * bitcoin address or a lightning invoice. Depending on which type it is 158 | * the app will navigate to the corresponding payment view. 159 | * This action can be called from a view event handler as does all 160 | * the necessary error handling and notification display. 161 | * @return {Promise} 162 | */ 163 | async checkType() { 164 | if (!this._store.payment.address) { 165 | return this._notification.display({ msg: 'Enter an invoice or address' }); 166 | } 167 | if (await this.decodeInvoice({ invoice: this._store.payment.address })) { 168 | this._nav.goPayLightningConfirm(); 169 | } else if (isAddress(this._store.payment.address)) { 170 | this._nav.goPayBitcoin(); 171 | } else { 172 | this._notification.display({ msg: 'Invalid invoice or address' }); 173 | } 174 | } 175 | 176 | /** 177 | * Attempt to decode a lightning invoice using the lnd grpc api. If it is 178 | * an invoice the amount and note store values will be set and the lightning 179 | * transaction fee will also be estimated. 180 | * @param {string} options.invoice The input to be validated 181 | * @return {Promise} If the input is a valid invoice 182 | */ 183 | async decodeInvoice({ invoice }) { 184 | try { 185 | const { payment, settings } = this._store; 186 | const request = await this._grpc.sendCommand('decodePayReq', { 187 | payReq: invoice, 188 | }); 189 | payment.amount = toAmount(request.numSatoshis, settings); 190 | payment.note = request.description; 191 | this.estimateLightningFee({ 192 | destination: request.destination, 193 | satAmt: request.numSatoshis, 194 | }); 195 | return true; 196 | } catch (err) { 197 | log.info(`Decoding payment request failed: ${err.message}`); 198 | return false; 199 | } 200 | } 201 | 202 | /** 203 | * Estimate the lightning transaction fee using the queryRoutes grpc api 204 | * after which the fee is set in the store. 205 | * @param {string} options.destination The lnd node that is to be payed 206 | * @param {number} options.satAmt The amount to be payed in satoshis 207 | * @return {Promise} 208 | */ 209 | async estimateLightningFee({ destination, satAmt }) { 210 | try { 211 | const { payment, settings } = this._store; 212 | const { routes } = await this._grpc.sendCommand('queryRoutes', { 213 | pubKey: destination, 214 | amt: satAmt, 215 | numRoutes: 1, 216 | }); 217 | payment.fee = toAmount(routes[0].totalFees, settings); 218 | } catch (err) { 219 | log.info(`Estimating lightning fee failed!`, err); 220 | } 221 | } 222 | 223 | /** 224 | * Estimate the on-chain transaction fee using the grpc api after which 225 | * the fee is set in the store. 226 | * @return {Promise} 227 | */ 228 | async estimateFee() { 229 | const { payment } = this._store; 230 | payment.feeEstimates = []; 231 | await this._fetchEstimate(LOW_TARGET_CONF, 'Low'); 232 | await this._fetchEstimate(MED_TARGET_CONF, 'Med'); 233 | await this._fetchEstimate(HIGH_TARGET_CONF, 'High'); 234 | payment.fee = payment.feeEstimates[1].fee; 235 | } 236 | 237 | async _fetchEstimate(targetConf, prio) { 238 | const { payment, settings } = this._store; 239 | const AddrToAmount = {}; 240 | AddrToAmount[payment.address] = toSatoshis(payment.amount, settings); 241 | const { feeSat } = await this._grpc.sendCommand('estimateFee', { 242 | AddrToAmount, 243 | targetConf, 244 | }); 245 | payment.feeEstimates.push({ 246 | fee: toAmount(feeSat, settings), 247 | targetConf, 248 | prio, 249 | }); 250 | } 251 | 252 | /** 253 | * Set the target_conf for the on-chain send operation and set a fee. 254 | * @param {number} options.targetConf The number blocks to target 255 | */ 256 | setTargetConf({ targetConf }) { 257 | const { payment } = this._store; 258 | payment.targetConf = targetConf; 259 | if (!payment.feeEstimates.length) return; 260 | payment.fee = payment.feeEstimates.find( 261 | e => e.targetConf === targetConf 262 | ).fee; 263 | } 264 | 265 | /** 266 | * Initialize the pay bitcoin confirm view by getting a fee estimate 267 | * from lnd and navigating to the view. 268 | * @return {Promise} 269 | */ 270 | async initPayBitcoinConfirm() { 271 | try { 272 | const { payment } = this._store; 273 | if (!payment.fee || !payment.sendAll) { 274 | await this.estimateFee(); 275 | } 276 | this._nav.goPayBitcoinConfirm(); 277 | } catch (err) { 278 | this._notification.display({ 279 | msg: `Fee estimation failed: ${err.message}`, 280 | err, 281 | }); 282 | } 283 | } 284 | 285 | /** 286 | * Send the specified amount as an on-chain transaction to the provided 287 | * bitcoin address and display a payment confirmation screen. 288 | * This action can be called from a view event handler as does all 289 | * the necessary error handling and notification display. 290 | * @return {Promise} 291 | */ 292 | async payBitcoin() { 293 | const timeout = setTimeout(() => { 294 | this._nav.goPayBitcoinConfirm(); 295 | this._notification.display({ 296 | type: 'error', 297 | msg: 'Sending transaction timed out!', 298 | }); 299 | }, PAYMENT_TIMEOUT); 300 | this._nav.goWait(); 301 | try { 302 | await this._sendPayment(); 303 | this._nav.goPayBitcoinDone(); 304 | } catch (err) { 305 | this._nav.goPayBitcoinConfirm(); 306 | this._notification.display({ msg: 'Sending transaction failed!', err }); 307 | } finally { 308 | clearTimeout(timeout); 309 | } 310 | } 311 | 312 | async _sendPayment() { 313 | const { payment, settings } = this._store; 314 | let amount = payment.sendAll ? 0 : toSatoshis(payment.amount, settings); 315 | await this._grpc.sendCommand('sendCoins', { 316 | addr: payment.address, 317 | amount, 318 | targetConf: payment.targetConf, 319 | sendAll: payment.sendAll, 320 | }); 321 | } 322 | 323 | /** 324 | * Send the amount specified in the invoice as a lightning transaction and 325 | * display the wait screen while the payment confirms. 326 | * This action can be called from a view event handler as does all 327 | * the necessary error handling and notification display. 328 | * @return {Promise} 329 | */ 330 | async payLightning() { 331 | let failed = false; 332 | const timeout = setTimeout(() => { 333 | failed = true; 334 | this._nav.goPaymentFailed(); 335 | }, PAYMENT_TIMEOUT); 336 | try { 337 | this._nav.goWait(); 338 | const invoice = this._store.payment.address; 339 | const stream = this._grpc.sendStreamCommand('sendPayment'); 340 | await new Promise((resolve, reject) => { 341 | stream.on('data', data => { 342 | if (data.paymentError) { 343 | reject(new Error(`Lightning payment error: ${data.paymentError}`)); 344 | } else { 345 | resolve(); 346 | } 347 | }); 348 | stream.on('error', reject); 349 | stream.write(JSON.stringify({ paymentRequest: invoice }), 'utf8'); 350 | }); 351 | if (failed) return; 352 | this._nav.goPayLightningDone(); 353 | } catch (err) { 354 | if (failed) return; 355 | this._nav.goPayLightningConfirm(); 356 | this._notification.display({ msg: 'Lightning payment failed!', err }); 357 | } finally { 358 | clearTimeout(timeout); 359 | } 360 | } 361 | } 362 | 363 | export default PaymentAction; 364 | -------------------------------------------------------------------------------- /src/action/channel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to set channel state within the app and to 3 | * call the corresponding GRPC apis for channel management. 4 | */ 5 | 6 | import { MED_TARGET_CONF } from '../config'; 7 | import { toSatoshis, poll, getTimeTilAvailable } from '../helper'; 8 | import * as log from './log'; 9 | 10 | class ChannelAction { 11 | constructor(store, grpc, nav, notification) { 12 | this._store = store; 13 | this._grpc = grpc; 14 | this._nav = nav; 15 | this._notification = notification; 16 | } 17 | 18 | // 19 | // Create channel actions 20 | // 21 | 22 | /** 23 | * Initiate the create channel view by resetting input values 24 | * and then navigating to the view. 25 | * @return {undefined} 26 | */ 27 | initCreate() { 28 | this._store.channel.pubkeyAtHost = ''; 29 | this._store.channel.amount = ''; 30 | this._nav.goChannelCreate(); 31 | } 32 | 33 | /** 34 | * Set the amount input for the create channel view. This amount 35 | * is either in btc or fiat depending on user settings. 36 | * @param {string} options.amount The string formatted number 37 | */ 38 | setAmount({ amount }) { 39 | this._store.channel.amount = amount; 40 | } 41 | 42 | /** 43 | * Set the channel public key and hostname in a single variable 44 | * which can be parsed before calling the create channel grpc api. 45 | * @param {string} options.pubkeyAtHost The combined public key and host 46 | */ 47 | setPubkeyAtHost({ pubkeyAtHost }) { 48 | this._store.channel.pubkeyAtHost = pubkeyAtHost; 49 | } 50 | 51 | // 52 | // Channel list actions 53 | // 54 | 55 | /** 56 | * Initiate the channel list view by navigating to the view and updating 57 | * the app's channel state by calling all necessary grpc apis. 58 | * @return {undefined} 59 | */ 60 | init() { 61 | this._nav.goChannels(); 62 | } 63 | 64 | /** 65 | * Select a channel item from the channel list view and then navigate 66 | * to the detail view to list channel parameters. 67 | * @param {Object} options.item The selected channel object 68 | * @return {undefined} 69 | */ 70 | select({ item }) { 71 | this._store.selectedChannel = item; 72 | this._nav.goChannelDetail(); 73 | } 74 | 75 | /** 76 | * Update the peers, channels, and pending channels in the app state 77 | * by querying all required grpc apis. 78 | * @return {Promise} 79 | */ 80 | async update() { 81 | await Promise.all([ 82 | this.getPeers(), 83 | this.getChannels(), 84 | this.getPendingChannels(), 85 | this.getClosedChannels(), 86 | ]); 87 | } 88 | 89 | /** 90 | * Poll the channels in the background since there is no streaming grpc api 91 | * @return {Promise} 92 | */ 93 | async pollChannels() { 94 | await poll(() => this.update()); 95 | } 96 | 97 | // 98 | // Generic channel actions 99 | // 100 | 101 | /** 102 | * List the open channels by calling the respective grpc api and updating 103 | * the channels array in the global store. 104 | * @return {Promise} 105 | */ 106 | async getChannels() { 107 | try { 108 | const { channels } = await this._grpc.sendCommand('listChannels'); 109 | this._store.channels = channels.map(channel => ({ 110 | remotePubkey: channel.remotePubkey, 111 | id: channel.chanId, 112 | capacity: channel.capacity, 113 | localBalance: channel.localBalance, 114 | remoteBalance: channel.remoteBalance, 115 | commitFee: channel.commitFee, 116 | channelPoint: channel.channelPoint, 117 | fundingTxId: this._parseChannelPoint(channel.channelPoint) 118 | .fundingTxidStr, 119 | active: channel.active, 120 | private: channel.private, 121 | status: 'open', 122 | })); 123 | } catch (err) { 124 | log.error('Listing channels failed', err); 125 | } 126 | } 127 | 128 | /** 129 | * List the pending channels by calling the respective grpc api and updating 130 | * the pendingChannels array and limbo balance in the global store. 131 | * @return {Promise} 132 | */ 133 | async getPendingChannels() { 134 | try { 135 | const response = await this._grpc.sendCommand('pendingChannels'); 136 | const mapPendingAttributes = channel => ({ 137 | remotePubkey: channel.remoteNodePub, 138 | capacity: channel.capacity, 139 | localBalance: channel.localBalance, 140 | remoteBalance: channel.remoteBalance, 141 | channelPoint: channel.channelPoint, 142 | fundingTxId: this._parseChannelPoint(channel.channelPoint) 143 | .fundingTxidStr, 144 | }); 145 | const pocs = response.pendingOpenChannels.map(poc => ({ 146 | ...mapPendingAttributes(poc.channel), 147 | confirmationHeight: poc.confirmationHeight, 148 | blocksTillOpen: poc.blocksTillOpen, 149 | commitFee: poc.commitFee, 150 | feePerKw: poc.feePerKw, 151 | status: 'pending-open', 152 | })); 153 | const pccs = response.pendingClosingChannels.map(pcc => ({ 154 | ...mapPendingAttributes(pcc.channel), 155 | closingTxId: pcc.closingTxid, 156 | status: 'pending-closing', 157 | })); 158 | const pfccs = response.pendingForceClosingChannels.map(pfcc => ({ 159 | ...mapPendingAttributes(pfcc.channel), 160 | closingTxId: pfcc.closingTxid, 161 | limboBalance: pfcc.limboBalance, 162 | maturityHeight: pfcc.maturityHeight, 163 | blocksTilMaturity: pfcc.blocksTilMaturity, 164 | timeTilAvailable: getTimeTilAvailable(pfcc.blocksTilMaturity), 165 | status: 'pending-force-closing', 166 | })); 167 | const wccs = response.waitingCloseChannels.map(wcc => ({ 168 | ...mapPendingAttributes(wcc.channel), 169 | limboBalance: wcc.limboBalance, 170 | status: 'waiting-close', 171 | })); 172 | this._store.pendingChannels = [].concat(pocs, pccs, pfccs, wccs); 173 | } catch (err) { 174 | log.error('Listing pending channels failed', err); 175 | } 176 | } 177 | 178 | /** 179 | * List the closed channels by calling the respective grpc api and updating 180 | * the closed channels array in the global store. 181 | * @return {Promise} 182 | */ 183 | async getClosedChannels() { 184 | try { 185 | const { channels } = await this._grpc.sendCommand('closedChannels'); 186 | this._store.closedChannels = channels.map(channel => ({ 187 | remotePubkey: channel.remotePubkey, 188 | capacity: channel.capacity, 189 | channelPoint: channel.channelPoint, 190 | fundingTxId: this._parseChannelPoint(channel.channelPoint) 191 | .fundingTxidStr, 192 | localBalance: channel.settledBalance, 193 | remoteBalance: channel.capacity - channel.settledBalance, 194 | closingTxId: channel.closingTxHash, 195 | status: 'closed', 196 | })); 197 | } catch (err) { 198 | log.error('Listing closed channels failed', err); 199 | } 200 | } 201 | 202 | /** 203 | * List the peers by calling the respective grpc api and updating 204 | * the peers array in the global store. 205 | * @return {Promise} 206 | */ 207 | async getPeers() { 208 | try { 209 | const { peers } = await this._grpc.sendCommand('listPeers'); 210 | this._store.peers = peers.map(peer => ({ 211 | pubKey: peer.pubKey, 212 | peerId: peer.peerId, 213 | address: peer.address, 214 | bytesSent: peer.bytesSent, 215 | bytesRecv: peer.bytesRecv, 216 | satSent: peer.satSent, 217 | satRecv: peer.satRecv, 218 | inbound: peer.inbound, 219 | pingTime: peer.pingTime, 220 | })); 221 | } catch (err) { 222 | log.error('Listing peers failed', err); 223 | } 224 | } 225 | 226 | /** 227 | * Attempt to connect to a peer and open a channel in a single call. 228 | * If a connection already exists, just a channel will be opened. 229 | * This action can be called from a view event handler as does all 230 | * the necessary error handling and notification display. 231 | * @return {Promise} 232 | */ 233 | async connectAndOpen() { 234 | try { 235 | const { channel, settings } = this._store; 236 | const amount = toSatoshis(channel.amount, settings); 237 | if (!channel.pubkeyAtHost.includes('@')) { 238 | return this._notification.display({ msg: 'Please enter pubkey@host' }); 239 | } 240 | this._nav.goChannels(); 241 | const pubkey = channel.pubkeyAtHost.split('@')[0]; 242 | const host = channel.pubkeyAtHost.split('@')[1]; 243 | await this.connectToPeer({ host, pubkey }); 244 | await this.openChannel({ pubkey, amount }); 245 | } catch (err) { 246 | this._nav.goChannelCreate(); 247 | this._notification.display({ msg: 'Creating channel failed!', err }); 248 | } 249 | } 250 | 251 | /** 252 | * Connect to peer and fail gracefully by catching exceptions and 253 | * logging their output. 254 | * @param {string} options.host The hostname of the peer 255 | * @param {string} options.pubkey The public key of the peer 256 | * @return {Promise} 257 | */ 258 | async connectToPeer({ host, pubkey }) { 259 | try { 260 | await this._grpc.sendCommand('connectPeer', { 261 | addr: { host, pubkey }, 262 | }); 263 | } catch (err) { 264 | log.info('Connecting to peer failed', err); 265 | } 266 | } 267 | 268 | /** 269 | * Open a channel to a peer without advertising it and update channel 270 | * state on data event from the streaming grpc api. 271 | * @param {string} options.pubkey The public key of the peer 272 | * @param {number} options.amount The amount in satoshis to fund the channel 273 | * @return {Promise} 274 | */ 275 | async openChannel({ pubkey, amount }) { 276 | const stream = this._grpc.sendStreamCommand('openChannel', { 277 | nodePubkey: Buffer.from(pubkey, 'hex'), 278 | localFundingAmount: amount, 279 | private: true, 280 | }); 281 | await new Promise((resolve, reject) => { 282 | stream.on('data', () => this.update()); 283 | stream.on('end', resolve); 284 | stream.on('error', reject); 285 | stream.on('status', status => log.info(`Opening channel: ${status}`)); 286 | }); 287 | } 288 | 289 | /** 290 | * Close the selected channel by attempting a cooperative close. 291 | * This action can be called from a view event handler as does all 292 | * the necessary error handling and notification display. 293 | * @return {Promise} 294 | */ 295 | async closeSelectedChannel() { 296 | try { 297 | const { selectedChannel: selected } = this._store; 298 | this._nav.goChannels(); 299 | await this.closeChannel({ 300 | channelPoint: selected.channelPoint, 301 | force: selected.status !== 'open' || !selected.active, 302 | }); 303 | } catch (err) { 304 | this._notification.display({ msg: 'Closing channel failed!', err }); 305 | } 306 | } 307 | 308 | /** 309 | * Close a channel using the grpc streaming api and update the state 310 | * on data events. Once the channel close is complete the channel will 311 | * be removed from the channels array in the store. 312 | * @param {string} options.channelPoint The channel identifier 313 | * @param {Boolean} options.force Force or cooperative close 314 | * @return {Promise} 315 | */ 316 | async closeChannel({ channelPoint, force = false }) { 317 | const stream = this._grpc.sendStreamCommand('closeChannel', { 318 | channelPoint: this._parseChannelPoint(channelPoint), 319 | force, 320 | targetConf: force ? undefined : MED_TARGET_CONF, 321 | }); 322 | await new Promise((resolve, reject) => { 323 | stream.on('data', data => { 324 | if (data.closePending) { 325 | this.update(); 326 | } 327 | if (data.chanClose) { 328 | this._removeClosedChannel(channelPoint); 329 | } 330 | }); 331 | stream.on('end', resolve); 332 | stream.on('error', reject); 333 | stream.on('status', status => log.info(`Closing channel: ${status}`)); 334 | }); 335 | } 336 | 337 | _parseChannelPoint(channelPoint) { 338 | if (!channelPoint || !channelPoint.includes(':')) { 339 | throw new Error('Invalid channel point'); 340 | } 341 | return { 342 | fundingTxidStr: channelPoint.split(':')[0], 343 | outputIndex: parseInt(channelPoint.split(':')[1], 10), 344 | }; 345 | } 346 | 347 | _removeClosedChannel(channelPoint) { 348 | const pc = this._store.pendingChannels; 349 | const channel = pc.find(c => c.channelPoint === channelPoint); 350 | if (channel) pc.splice(pc.indexOf(channel)); 351 | } 352 | } 353 | 354 | export default ChannelAction; 355 | -------------------------------------------------------------------------------- /src/action/wallet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview actions to set wallet state within the app and to 3 | * call the corresponding GRPC apis for updating wallet balances. 4 | */ 5 | 6 | import { toBuffer, checkHttpStatus, nap, poll } from '../helper'; 7 | import { 8 | MIN_PASSWORD_LENGTH, 9 | NOTIFICATION_DELAY, 10 | RATE_DELAY, 11 | RECOVERY_WINDOW, 12 | } from '../config'; 13 | import { when } from 'mobx'; 14 | import * as log from './log'; 15 | 16 | class WalletAction { 17 | constructor(store, grpc, db, nav, notification, file, backup) { 18 | this._store = store; 19 | this._grpc = grpc; 20 | this._db = db; 21 | this._nav = nav; 22 | this._notification = notification; 23 | this._file = file; 24 | this._backup = backup; 25 | } 26 | 27 | // 28 | // Verify Seed actions 29 | // 30 | 31 | /** 32 | * Initialize the seed verify view by resetting input values 33 | * and then navigating to the view. 34 | * @return {undefined} 35 | */ 36 | initSeedVerify() { 37 | this._store.wallet.seedVerify = ['', '', '']; 38 | this._nav.goSeedVerify(); 39 | } 40 | 41 | /** 42 | * Set the verify seed input by validation the seed word and 43 | * seed index. 44 | * @param {string} options.word The seed word 45 | * @param {number} options.index The seed index 46 | */ 47 | setSeedVerify({ word = '', index }) { 48 | this._store.wallet.seedVerify[index] = word.toLowerCase().trim(); 49 | } 50 | 51 | /** 52 | * Set the restore seed input by the seed word and 53 | * seed index. 54 | * @param {string} options.word The seed word 55 | * @param {number} options.index The seed index 56 | */ 57 | setRestoreSeed({ word, index }) { 58 | this._store.restoreSeedMnemonic[index] = word.trim(); 59 | } 60 | 61 | /** 62 | * Set which seed restore input is in focus. 63 | * @param {number} options.index The index of the input. 64 | */ 65 | setFocusedRestoreInd({ index }) { 66 | this._store.wallet.focusedRestoreInd = index; 67 | } 68 | 69 | // 70 | // Wallet Password actions 71 | // 72 | 73 | /** 74 | * Initialize the set password view by resetting input values 75 | * and then navigating to the view. 76 | * @return {undefined} 77 | */ 78 | initSetPassword() { 79 | this._store.wallet.newPassword = ''; 80 | this._store.wallet.passwordVerify = ''; 81 | this._nav.goSetPassword(); 82 | } 83 | 84 | /** 85 | * Initialize the password view by resetting input values 86 | * and then navigating to the view. 87 | * @return {undefined} 88 | */ 89 | initPassword() { 90 | this._store.wallet.password = ''; 91 | this._nav.goPassword(); 92 | } 93 | 94 | /** 95 | * Initialize the reset password user flow by resetting input values 96 | * and then navigating to the initial view. 97 | * @return {undefined} 98 | */ 99 | initResetPassword() { 100 | this._store.wallet.password = ''; 101 | this._store.wallet.passwordVerify = ''; 102 | this._store.wallet.newPassword = ''; 103 | this._nav.goResetPasswordCurrent(); 104 | } 105 | 106 | /** 107 | * Set the password input for the password view. 108 | * @param {string} options.password The wallet password 109 | */ 110 | setPassword({ password }) { 111 | this._store.wallet.password = password; 112 | } 113 | 114 | /** 115 | * Set the new password input for the reset password: new password view. 116 | * @param {string} options.password The wallet password 117 | */ 118 | setNewPassword({ password }) { 119 | this._store.wallet.newPassword = password; 120 | } 121 | 122 | /** 123 | * Set the verify password input for the password view. 124 | * @param {string} options.password The wallet password a second time 125 | */ 126 | setPasswordVerify({ password }) { 127 | this._store.wallet.passwordVerify = password; 128 | } 129 | 130 | // 131 | // Wallet actions 132 | // 133 | 134 | /** 135 | * Initialize the wallet by trying to generate a new seed. If seed 136 | * generation in lnd fails, the app assumes a wallet already exists 137 | * and wallet unlock via password input will be initiated. 138 | * @return {Promise} 139 | */ 140 | async init() { 141 | try { 142 | await this.generateSeed(); 143 | this._store.firstStart = true; 144 | this._nav.goLoader(); 145 | await nap(NOTIFICATION_DELAY); 146 | this._nav.goSelectSeed(); 147 | } catch (err) { 148 | this.initPassword(); 149 | } 150 | } 151 | 152 | /** 153 | * Update the wallet on-chain and channel balances. 154 | * @return {Promise} 155 | */ 156 | async update() { 157 | await Promise.all([ 158 | this.getBalance(), 159 | this.getChannelBalance(), 160 | this.getNewAddress(), 161 | ]); 162 | } 163 | 164 | /** 165 | * Poll the wallet balances in the background since there is no streaming 166 | * grpc api yet 167 | * @return {Promise} 168 | */ 169 | async pollBalances() { 170 | await poll(() => this.update()); 171 | } 172 | 173 | /** 174 | * Generate a new wallet seed. This needs to be done the first time the 175 | * app is started. 176 | * @return {Promise} 177 | */ 178 | async generateSeed() { 179 | const response = await this._grpc.sendUnlockerCommand('GenSeed'); 180 | this._store.seedMnemonic = response.cipherSeedMnemonic; 181 | } 182 | 183 | /** 184 | * Verify that the user has written down the generated seed correctly by 185 | * checking three random seed words. If the match continue to setting the 186 | * wallet password. 187 | * @return {undefined} 188 | */ 189 | async checkSeed() { 190 | const { 191 | wallet: { seedVerify }, 192 | seedMnemonic, 193 | seedVerifyIndexes, 194 | } = this._store; 195 | if ( 196 | seedVerify[0] !== seedMnemonic[seedVerifyIndexes[0] - 1] || 197 | seedVerify[1] !== seedMnemonic[seedVerifyIndexes[1] - 1] || 198 | seedVerify[2] !== seedMnemonic[seedVerifyIndexes[2] - 1] 199 | ) { 200 | return this._notification.display({ msg: 'Seed words do not match!' }); 201 | } 202 | this.initSetPassword(); 203 | } 204 | 205 | /** 206 | * Check the wallet password that was chosen by the user has the correct 207 | * length and that it was also entered correctly twice to make sure that 208 | * there was no typo. 209 | * @return {Promise} 210 | */ 211 | async checkNewPassword() { 212 | const { newPassword, passwordVerify } = this._store.wallet; 213 | let errorMsg; 214 | if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) { 215 | errorMsg = `Set a password with at least ${MIN_PASSWORD_LENGTH} characters.`; 216 | } else if (newPassword !== passwordVerify) { 217 | errorMsg = 'Passwords do not match!'; 218 | } 219 | if (errorMsg) { 220 | this.initSetPassword(); 221 | return this._notification.display({ msg: errorMsg }); 222 | } 223 | await this.initWallet({ 224 | walletPassword: newPassword, 225 | recoveryWindow: this._store.settings.restoring ? RECOVERY_WINDOW : 0, 226 | seedMnemonic: this._store.settings.restoring 227 | ? this._store.restoreSeedMnemonic.toJSON() 228 | : this._store.seedMnemonic.toJSON(), 229 | }); 230 | } 231 | 232 | /** 233 | * Check the wallet password that was chosen by the user has the correct 234 | * length and that it was also entered correctly twice to make sure that 235 | * there was no typo. 236 | * @return {Promise} 237 | */ 238 | async checkResetPassword() { 239 | const { password, newPassword, passwordVerify } = this._store.wallet; 240 | let errorMsg; 241 | if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) { 242 | errorMsg = `Set a password with at least ${MIN_PASSWORD_LENGTH} characters.`; 243 | } else if (newPassword === password) { 244 | errorMsg = 'New password must not match old password.'; 245 | } else if (newPassword !== passwordVerify) { 246 | errorMsg = 'Passwords do not match!'; 247 | } 248 | if (errorMsg) { 249 | this.initResetPassword(); 250 | return this._notification.display({ msg: errorMsg }); 251 | } 252 | this._nav.goWait(); 253 | await this.resetPassword({ 254 | currentPassword: password, 255 | newPassword: newPassword, 256 | }); 257 | } 258 | 259 | /** 260 | * Initiate the lnd wallet using the generated seed and password. If this 261 | * is success set `walletUnlocked` to true and navigate to the seed success 262 | * screen. 263 | * @param {string} options.walletPassword The user chosen password 264 | * @param {Array} options.seedMnemonic The seed words to generate the wallet 265 | * @param {number} options.recoveryWindow The number of addresses to recover 266 | * @return {Promise} 267 | */ 268 | async initWallet({ walletPassword, seedMnemonic, recoveryWindow = 0 }) { 269 | try { 270 | await this.deleteDB(); 271 | const channelBackups = await this.checkChannelBackup(); 272 | await this._grpc.sendUnlockerCommand('InitWallet', { 273 | walletPassword: toBuffer(walletPassword), 274 | cipherSeedMnemonic: seedMnemonic, 275 | recoveryWindow: recoveryWindow, 276 | channelBackups, 277 | }); 278 | this._store.walletUnlocked = true; 279 | this._nav.goSeedSuccess(); 280 | } catch (err) { 281 | if (this._store.settings.restoring) { 282 | this._notification.display({ 283 | type: 'error', 284 | msg: `Restoring wallet failed: ${err.message}`, 285 | }); 286 | this.initRestoreWallet(); 287 | } else { 288 | this._notification.display({ 289 | type: 'error', 290 | msg: `Initializing wallet failed.`, 291 | }); 292 | } 293 | } 294 | } 295 | 296 | /** 297 | * Delete the wallet.db file. This allows the user to restore their wallet 298 | * (including channel state) from the seed if they've they've forgotten the 299 | * wallet pin/password. We need to delete both mainnet and testnet wallet 300 | * files since we haven't set `store.network` at this point in the app life 301 | * cycle yet (which happens later when we query getInfo). 302 | * @return {Promise} 303 | */ 304 | async deleteDB() { 305 | if (!this._file) { 306 | return; 307 | } 308 | await Promise.all([ 309 | this._file.deleteWalletDB('testnet'), 310 | this._file.deleteWalletDB('mainnet'), 311 | ]); 312 | } 313 | 314 | async checkChannelBackup() { 315 | if (!this._backup || !this._store.settings.restoring) { 316 | return; 317 | } 318 | const scbBuffer = await this._backup.fetchChannelBackup(); 319 | if (!scbBuffer) { 320 | return; 321 | } 322 | return { 323 | multiChanBackup: { 324 | multiChanBackup: scbBuffer, 325 | }, 326 | }; 327 | } 328 | 329 | /** 330 | * Initialize the seed flow by navigating to the proper next view and 331 | * resetting the current seed index. 332 | * @return {undefined} 333 | */ 334 | initSeed() { 335 | this._store.wallet.seedIndex = 0; 336 | this._nav.goSeedIntro ? this._nav.goSeedIntro() : this._nav.goSeed(); 337 | } 338 | 339 | /** 340 | * Initialize the next seed view by setting a new seedIndex or, if all seed 341 | * words have been displayed, navigating to the mobile seed verify view. 342 | * @return {undefined} 343 | */ 344 | initNextSeedPage() { 345 | if (this._store.wallet.seedIndex < 16) { 346 | this._store.wallet.seedIndex += 8; 347 | } else { 348 | this.initSeedVerify(); 349 | } 350 | } 351 | 352 | /** 353 | * Initialize the previous seed view by setting a new seedIndex or, if on the 354 | * first seed page, navigating to the select seed view. 355 | * @return {undefined} 356 | */ 357 | initPrevSeedPage() { 358 | if (this._store.wallet.seedIndex >= 8) { 359 | this._store.wallet.seedIndex -= 8; 360 | } else { 361 | this._nav.goSelectSeed(); 362 | } 363 | } 364 | 365 | /** 366 | * Initialize the restore wallet view by resetting input values and then 367 | * navigating to the view. 368 | * @return {undefined} 369 | */ 370 | initRestoreWallet() { 371 | this._store.restoreSeedMnemonic = Array(24).fill(''); 372 | this._store.wallet.restoreIndex = 0; 373 | this._nav.goRestoreSeed(); 374 | } 375 | 376 | /** 377 | * Initialize the next restore wallet view by setting a new restoreIndex or, 378 | * if all seed words have been entered, navigating to the password entry 379 | * view. 380 | * @return {undefined} 381 | */ 382 | initNextRestorePage() { 383 | if (this._store.wallet.restoreIndex < 21) { 384 | this._store.wallet.restoreIndex += 3; 385 | this._store.wallet.focusedRestoreInd = this._store.wallet.restoreIndex; 386 | } else { 387 | this.initSetPassword(); 388 | } 389 | } 390 | 391 | /** 392 | * Initialize the previous restore wallet view by setting a new restoreIndex 393 | * or, if on the first seed entry page, navigating to the select seed view. 394 | * @return {undefined} 395 | */ 396 | initPrevRestorePage() { 397 | if (this._store.wallet.restoreIndex >= 3) { 398 | this._store.wallet.restoreIndex -= 3; 399 | this._store.wallet.focusedRestoreInd = this._store.wallet.restoreIndex; 400 | } else { 401 | this._nav.goBack ? this._nav.goBack() : this._nav.goSelectSeed(); 402 | } 403 | } 404 | 405 | /** 406 | * Update the current wallet password of the user. 407 | * @param {string} options.currentPassword The current password of the user. 408 | * @param {string} options.newPassword The new password of the user. 409 | * @return {Promise} 410 | */ 411 | async resetPassword({ currentPassword, newPassword }) { 412 | try { 413 | await this._grpc.restartLnd(); 414 | this._store.walletUnlocked = false; 415 | this._store.lndReady = false; 416 | await this._grpc.initUnlocker(); 417 | await this._grpc.sendUnlockerCommand('ChangePassword', { 418 | currentPassword: toBuffer(currentPassword), 419 | newPassword: toBuffer(newPassword), 420 | }); 421 | this._store.walletUnlocked = true; 422 | this._nav.goResetPasswordSaved(); 423 | } catch (err) { 424 | this._notification.display({ msg: 'Password change failed', err }); 425 | this._nav.goResetPasswordCurrent(); 426 | } 427 | } 428 | 429 | /** 430 | * Check the password input by the user by attempting to unlock the wallet. 431 | * @return {Promise} 432 | */ 433 | async checkPassword() { 434 | const { password } = this._store.wallet; 435 | await this.unlockWallet({ walletPassword: password }); 436 | } 437 | 438 | /** 439 | * Unlock the wallet by calling the grpc api with the user chosen password. 440 | * @param {string} options.walletPassword The password used to encrypt the wallet 441 | * @return {Promise} 442 | */ 443 | async unlockWallet({ walletPassword }) { 444 | try { 445 | this._nav.goWait(); 446 | await this._grpc.sendUnlockerCommand('UnlockWallet', { 447 | walletPassword: toBuffer(walletPassword), 448 | recoveryWindow: this._store.settings.restoring ? 250 : 0, 449 | }); 450 | this._store.walletUnlocked = true; 451 | when( 452 | () => this._store.lndReady && this._store.walletAddress, 453 | () => this._nav.goHome() 454 | ); 455 | } catch (err) { 456 | this.initPassword(); 457 | this._notification.display({ type: 'error', msg: 'Invalid password' }); 458 | } 459 | } 460 | 461 | /** 462 | * Toggle if fiat or btc should be use as the primary amount display in the 463 | * application. Aftwards save the user's current preference on disk. 464 | * @return {undefined} 465 | */ 466 | toggleDisplayFiat() { 467 | this._store.settings.displayFiat = !this._store.settings.displayFiat; 468 | this._db.save(); 469 | } 470 | 471 | /** 472 | * Fetch the on-chain wallet balances using the lnd grpc api and set the 473 | * corresponding values on the global store. 474 | * @return {Promise} 475 | */ 476 | async getBalance() { 477 | try { 478 | const r = await this._grpc.sendCommand('WalletBalance'); 479 | this._store.balanceSatoshis = r.totalBalance; 480 | this._store.confirmedBalanceSatoshis = r.confirmedBalance; 481 | this._store.unconfirmedBalanceSatoshis = r.unconfirmedBalance; 482 | } catch (err) { 483 | log.error('Getting wallet balance failed', err); 484 | } 485 | } 486 | 487 | /** 488 | * Fetch the lightning channel balances using the lnd grpc api and set the 489 | * corresponding values on the global store. 490 | * @return {Promise} 491 | */ 492 | async getChannelBalance() { 493 | try { 494 | const r = await this._grpc.sendCommand('ChannelBalance'); 495 | this._store.channelBalanceSatoshis = r.balance; 496 | this._store.pendingBalanceSatoshis = r.pendingOpenBalance; 497 | } catch (err) { 498 | log.error('Getting channel balance failed', err); 499 | } 500 | } 501 | 502 | /** 503 | * Ensure that the wallet address is non-null before navigating to the 504 | * NewAddress view during onboarding. 505 | * This is necessary because the wallet address may be null if neutrino 506 | * has not started syncing by the time the user finishes verifying 507 | * their seed. 508 | * @return {undefined} 509 | */ 510 | initInitialDeposit() { 511 | if (this._store.walletAddress) { 512 | this._nav.goNewAddress(); 513 | } else { 514 | this._nav.goWait(); 515 | when(() => this._store.walletAddress, () => this._nav.goNewAddress()); 516 | } 517 | } 518 | 519 | /** 520 | * Fetch a new on-chain bitcoin address which can be used to fund the wallet 521 | * or receive an on-chain transaction from another user. 522 | * @return {Promise} 523 | */ 524 | async getNewAddress() { 525 | try { 526 | const { address } = await this._grpc.sendCommand('NewAddress', { 527 | type: 3, // UNUSED_NESTED_PUBKEY_HASH = 3 528 | }); 529 | this._store.walletAddress = address; 530 | } catch (err) { 531 | log.error('Getting new wallet address failed', err); 532 | } 533 | } 534 | 535 | /** 536 | * Poll for the current btc/fiat exchange rate based on the currently selected 537 | * fiat currency every 15 minutes. 538 | * @return {Promise} 539 | */ 540 | async pollExchangeRate() { 541 | await poll(() => this.getExchangeRate(), RATE_DELAY); 542 | } 543 | 544 | /** 545 | * Fetch a current btc/fiat exchange rate based on the currently selected 546 | * fiat currency and persist the value on disk for the next time the app 547 | * starts up. 548 | * @return {Promise} 549 | */ 550 | async getExchangeRate() { 551 | try { 552 | const fiat = this._store.settings.fiat; 553 | const uri = 554 | 'https://nodes.lightning.computer/fiat/v1/btc-exchange-rates.json'; 555 | const response = checkHttpStatus(await fetch(uri)); 556 | const tickers = (await response.json()).tickers; 557 | const rate = tickers.find(t => t.ticker.toLowerCase() === fiat).rate; 558 | this._store.settings.exchangeRate[fiat] = 100 / rate; 559 | this._db.save(); 560 | } catch (err) { 561 | log.error('Getting exchange rate failed', err); 562 | } 563 | } 564 | } 565 | 566 | export default WalletAction; 567 | --------------------------------------------------------------------------------