diff --git a/src/action/nav.js b/src/action/nav.js index 3f9a53d2f..d4560c79c 100644 --- a/src/action/nav.js +++ b/src/action/nav.js @@ -13,6 +13,10 @@ class NavAction { this._store.route = 'Loader'; } + goSelectSeed() { + this._store.route = 'SelectSeed'; + } + goSeed() { this._store.route = 'Seed'; } @@ -21,8 +25,12 @@ class NavAction { this._store.route = 'SeedVerify'; } - goRestoreWallet() { - // this._store.route = 'RestoreWallet'; + goRestoreSeed() { + this._store.route = 'RestoreSeed'; + } + + goRestorePassword() { + this._store.route = 'RestorePassword'; } goSeedSuccess() { diff --git a/src/action/wallet.js b/src/action/wallet.js index bbf25b767..1a9266127 100644 --- a/src/action/wallet.js +++ b/src/action/wallet.js @@ -5,7 +5,12 @@ import { observe, when } from 'mobx'; import { toBuffer, parseSat, checkHttpStatus, nap, poll } from '../helper'; -import { MIN_PASSWORD_LENGTH, NOTIFICATION_DELAY, RATE_DELAY } from '../config'; +import { + MIN_PASSWORD_LENGTH, + NOTIFICATION_DELAY, + RATE_DELAY, + RECOVERY_WINDOW, +} from '../config'; import * as log from './log'; class WalletAction { @@ -41,6 +46,16 @@ class WalletAction { this._store.wallet.seedVerify[index] = word; } + /** + * Set the restore seed input by the seed word and + * seed index. + * @param {string} options.word The seed word + * @param {number} options.index The seed index + */ + setRestoreSeed({ word, index }) { + this._store.wallet.restoreSeed[index] = word; + } + // // Wallet Password actions // @@ -82,6 +97,14 @@ class WalletAction { this._store.wallet.passwordVerify = password; } + /** + * Set whether or not we're restoring the wallet. + * @param {boolean} options.restoring Whether or not we're restoring. + */ + setRestoringWallet({ restoring }) { + this._store.wallet.restoring = restoring; + } + // // Wallet actions // @@ -98,7 +121,7 @@ class WalletAction { this._store.firstStart = true; this._nav.goLoader(); await nap(NOTIFICATION_DELAY); - this._nav.goSeed(); + this._nav.goSelectSeed(); } catch (err) { this.initPassword(); } @@ -181,18 +204,60 @@ class WalletAction { * screen. * @param {string} options.walletPassword The user chosen password * @param {Array} options.seedMnemonic The seed words to generate the wallet + * @param {number} options.recoveryWindow The number of addresses to recover * @return {Promise} */ - async initWallet({ walletPassword, seedMnemonic }) { + async initWallet({ walletPassword, seedMnemonic, recoveryWindow = 0 }) { try { await this._grpc.sendUnlockerCommand('InitWallet', { wallet_password: toBuffer(walletPassword), cipher_seed_mnemonic: seedMnemonic, + recovery_window: recoveryWindow, }); this._store.walletUnlocked = true; this._nav.goSeedSuccess(); } catch (err) { - this._notification.display({ msg: 'Initializing wallet failed', err }); + this._notification.display({ + type: 'error', + msg: `Initializing wallet failed: ${err.details}`, + }); + } + } + + /** + * Initialize the restore wallet view by resetting input values and then + * navigating to the view. + * @return {undefined} + */ + initRestoreWallet() { + this._store.wallet.restoreIndex = 0; + this._nav.goRestoreSeed(); + } + + /** + * Initialize the next restore wallet view by setting a new restoreIndex or, + * if all seed words have been entered, navigating to the password entry + * view. + * @return {undefined} + */ + initNextRestorePage() { + if (this._store.wallet.restoreIndex < 21) { + this._store.wallet.restoreIndex += 3; + } else { + this._nav.goRestorePassword(); + } + } + + /** + * Initialize the previous restore wallet view by setting a new restoreIndex + * or, if on the first seed entry page, navigating to the select seed view. + * @return {undefined} + */ + initPrevRestorePage() { + if (this._store.wallet.restoreIndex >= 3) { + this._store.wallet.restoreIndex -= 3; + } else { + this._nav.goSelectSeed(); } } @@ -205,6 +270,20 @@ class WalletAction { await this.unlockWallet({ walletPassword: password }); } + /** + * Initialize the wallet with the password input the seed that was already + * inputted, and the default recovery window. + * @return {Promise} + */ + async restoreWallet() { + const { password, restoreSeed } = this._store.wallet; + await this.initWallet({ + walletPassword: password, + seedMnemonic: restoreSeed.toJSON(), + recoveryWindow: RECOVERY_WINDOW, + }); + } + /** * Unlock the wallet by calling the grpc api with the user chosen password. * @param {string} options.walletPassword The password used to encrypt the wallet diff --git a/src/computed/seed.js b/src/computed/seed.js index 085c1e04c..992062ddf 100644 --- a/src/computed/seed.js +++ b/src/computed/seed.js @@ -17,6 +17,19 @@ const ComputedSeed = store => { const c2 = formatOrdinal(seedVerifyIndexes[2]); return `Type the ${c0}, ${c1}, and ${c2} words of your recovery phrase.`; }), + restoreIndexes: computed(() => [...Array(24).keys()].map(x => ++x)), + restoreVerifyIndexes: computed(() => { + const { restoreIndexes } = store; + const { restoreIndex } = store.wallet; + return restoreIndexes.slice(restoreIndex, restoreIndex + 3); + }), + restoreVerifyCopy: computed(() => { + const { restoreVerifyIndexes } = store; + const c0 = formatOrdinal(restoreVerifyIndexes[0]); + const c1 = formatOrdinal(restoreVerifyIndexes[1]); + const c2 = formatOrdinal(restoreVerifyIndexes[2]); + return `Type the ${c0}, ${c1}, and ${c2} words of your recovery phrase.`; + }), }); }; diff --git a/src/config.js b/src/config.js index f80868f5c..e028c925e 100644 --- a/src/config.js +++ b/src/config.js @@ -19,6 +19,7 @@ module.exports.PREFIX_URI = `${prefixName}:`; module.exports.DEFAULT_ROUTE = 'Welcome'; module.exports.MIN_PASSWORD_LENGTH = 8; module.exports.MAX_LOG_LENGTH = 10000; +module.exports.RECOVERY_WINDOW = 250; module.exports.UNITS = { sat: { display: 'SAT', displayLong: 'Satoshi', denominator: 1 }, diff --git a/src/store.js b/src/store.js index ad1f4303c..33e0d4232 100644 --- a/src/store.js +++ b/src/store.js @@ -40,6 +40,9 @@ export class Store { password: '', passwordVerify: '', seedVerify: ['', '', ''], + restoring: false, + restoreIndex: 0, + restoreSeed: Array(24).fill(''), }, transactions: [], selectedTransaction: null, diff --git a/src/view/main.js b/src/view/main.js index 66920b1f7..3f79c28e6 100644 --- a/src/view/main.js +++ b/src/view/main.js @@ -4,10 +4,13 @@ import Container from '../component/container'; import { NotificationBar } from '../component/notification'; import Welcome from './welcome'; import Loader from './loader'; +import SelectSeed from './select-seed'; import Seed from './seed'; import SeedVerify from './seed-verify'; import SeedSuccess from './seed-success'; import SetPassword from './set-password'; +import RestoreSeed from './restore-seed'; +import RestorePassword from './restore-password'; import Password from './password'; import NewAddress from './new-address'; import LoaderSyncing from './loader-syncing'; @@ -56,6 +59,9 @@ class MainView extends Component { /> {route === 'Welcome' && } {route === 'Loader' && } + {route === 'SelectSeed' && ( + + )} {route === 'Seed' && } {route === 'SeedVerify' && ( @@ -64,6 +70,12 @@ class MainView extends Component { {route === 'SetPassword' && ( )} + {route === 'RestoreSeed' && ( + + )} + {route === 'RestorePassword' && ( + + )} {route === 'Password' && } {route === 'NewAddress' && ( diff --git a/src/view/restore-password.js b/src/view/restore-password.js new file mode 100644 index 000000000..881fd85be --- /dev/null +++ b/src/view/restore-password.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { observer } from 'mobx-react'; +import PropTypes from 'prop-types'; +import Background from '../component/background'; +import MainContent from '../component/main-content'; +import { H1Text } from '../component/text'; +import { Header } from '../component/header'; +import { Button, BackButton, GlasButton } from '../component/button'; +import { InputField } from '../component/field'; +import Card from '../component/card'; +import { FormSubText, FormStretcher } from '../component/form'; + +// +// Restore Wallet Password View +// + +const styles = StyleSheet.create({ + content: { + justifyContent: 'flex-end', + }, + title: { + textAlign: 'center', + marginBottom: 20, + }, + card: { + maxHeight: 350, + maxWidth: 680, + paddingLeft: 45, + paddingRight: 45, + paddingBottom: 50, + }, +}); + +const RestorePasswordView = ({ store, wallet, nav }) => ( + +
+ nav.goSelectSeed()} /> +
+ + + Restore wallet + + + Please enter your password. + + wallet.setPassword({ password })} + onSubmitEditing={() => wallet.restoreWallet()} + /> + + + wallet.restoreWallet()}>Restore + +
+); + +RestorePasswordView.propTypes = { + store: PropTypes.object.isRequired, + wallet: PropTypes.object.isRequired, + nav: PropTypes.object.isRequired, +}; + +export default observer(RestorePasswordView); diff --git a/src/view/restore-wallet.js b/src/view/restore-seed.js similarity index 67% rename from src/view/restore-wallet.js rename to src/view/restore-seed.js index 5bc261527..eeabdd568 100644 --- a/src/view/restore-wallet.js +++ b/src/view/restore-seed.js @@ -12,7 +12,7 @@ import { Header } from '../component/header'; import Card from '../component/card'; // -// Restore Wallet View +// Restore Wallet Seed View // const styles = StyleSheet.create({ @@ -32,10 +32,10 @@ const styles = StyleSheet.create({ }, }); -const RestoreWalletView = ({ store, nav, wallet }) => ( +const RestoreSeedView = ({ store, wallet }) => (
- nav.goSeed()} /> + wallet.initPrevRestorePage()} />
@@ -43,27 +43,28 @@ const RestoreWalletView = ({ store, nav, wallet }) => ( Restore your wallet - {store.seedVerifyCopy} - {store.seedVerifyIndexes.map((seedIndex, i) => ( + {store.restoreVerifyCopy} + {store.restoreVerifyIndexes.map((seedIndex, i) => ( wallet.setSeedVerify({ word, index: i })} + value={store.wallet.restoreSeed[seedIndex - 1]} + onChangeText={word => + wallet.setRestoreSeed({ word, index: seedIndex - 1 }) + } key={i} autoFocus={i === 0} - onSubmitEditing={() => wallet.checkSeed()} + onSubmitEditing={() => wallet.initNextRestorePage()} /> ))} - wallet.checkSeed()}>Next + wallet.initNextRestorePage()}>Next
); -RestoreWalletView.propTypes = { +RestoreSeedView.propTypes = { store: PropTypes.object.isRequired, - nav: PropTypes.object.isRequired, wallet: PropTypes.object.isRequired, }; -export default observer(RestoreWalletView); +export default observer(RestoreSeedView); diff --git a/src/view/select-seed.js b/src/view/select-seed.js new file mode 100644 index 000000000..0fa748268 --- /dev/null +++ b/src/view/select-seed.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { observer } from 'mobx-react'; +import PropTypes from 'prop-types'; +import Background from '../component/background'; +import MainContent from '../component/main-content'; +import { H1Text, CopyText } from '../component/text'; +import { RadioButton, GlasButton } from '../component/button'; +import { SettingItem } from '../component/list'; +import { color } from '../component/style'; + +// +// Select Seed View +// + +const styles = StyleSheet.create({ + content: { + justifyContent: 'center', + paddingLeft: 50, + paddingRight: 50, + }, + copyTxt: { + textAlign: 'center', + marginTop: 10, + maxWidth: 450, + }, + list: { + marginTop: 50, + width: 400, + }, +}); + +const SelectSeedView = ({ store, wallet, nav }) => ( + + + Recovery phrase? + + If you already have a recovery phrase, you can use that now. Otherwise, + you should generate a new wallet. + + + wallet.setRestoringWallet({ restoring: false })} + > + + + wallet.setRestoringWallet({ restoring: true })} + > + + + + + + store.wallet.restoring ? wallet.initRestoreWallet() : nav.goSeed() + } + > + Next + + +); + +SelectSeedView.propTypes = { + store: PropTypes.object.isRequired, + wallet: PropTypes.object.isRequired, + nav: PropTypes.object.isRequired, +}; + +export default observer(SelectSeedView); diff --git a/src/view/setting-fiat.js b/src/view/setting-fiat.js index 73a1f1ec0..17bfd19b9 100644 --- a/src/view/setting-fiat.js +++ b/src/view/setting-fiat.js @@ -28,7 +28,7 @@ const styles = StyleSheet.create({ const SettingFiatView = ({ store, nav, setting }) => { return ( - +
nav.goSettings()} /> diff --git a/src/view/setting-unit.js b/src/view/setting-unit.js index 728ac4364..a62ef4b2b 100644 --- a/src/view/setting-unit.js +++ b/src/view/setting-unit.js @@ -28,7 +28,7 @@ const styles = StyleSheet.create({ const SettingUnitView = ({ store, nav, setting }) => { return ( - <Background color={color.blackDark} style={styles.wrapper}> + <Background color={color.blackDark}> <Header separator> <BackButton onPress={() => nav.goSettings()} /> <Title title="Bitcoin Units" /> diff --git a/stories/screen-story.js b/stories/screen-story.js index 6dfa0d1db..5da47ede7 100644 --- a/stories/screen-story.js +++ b/stories/screen-story.js @@ -41,14 +41,16 @@ import PayBitcoinDone from '../src/view/pay-bitcoin-done'; import NoRoute from '../src/view/no-route'; import Loader from '../src/view/loader'; import LoaderSyncing from '../src/view/loader-syncing'; +import SelectSeed from '../src/view/select-seed'; import SeedSuccess from '../src/view/seed-success'; import Seed from '../src/view/seed'; import SeedVerify from '../src/view/seed-verify'; import SetPassword from '../src/view/set-password'; import Password from '../src/view/password'; +import RestorePassword from '../src/view/restore-password'; import NewAddress from '../src/view/new-address'; import Wait from '../src/view/wait'; -import RestoreWallet from '../src/view/restore-wallet'; +import RestoreSeed from '../src/view/restore-seed'; const store = new Store(); store.init(); @@ -81,16 +83,22 @@ sinon.stub(channel, 'closeSelectedChannel'); storiesOf('Screens', module) .add('Welcome', () => <Welcome />) .add('Loader - First Time', () => <Loader />) + .add('Select Seed', () => ( + <SelectSeed store={store} wallet={wallet} nav={nav} /> + )) .add('Seed', () => <Seed store={store} wallet={wallet} />) .add('Seed Verify', () => ( <SeedVerify store={store} nav={nav} wallet={wallet} /> )) - .add('Restore Wallet', () => ( - <RestoreWallet store={store} nav={nav} wallet={wallet} /> + .add('Restore Wallet: Seed', () => ( + <RestoreSeed store={store} wallet={wallet} /> )) .add('Seed Success', () => <SeedSuccess wallet={wallet} />) .add('Set Password', () => <SetPassword store={store} wallet={wallet} />) .add('Password', () => <Password store={store} wallet={wallet} />) + .add('Restore Wallet: Password', () => ( + <RestorePassword store={store} wallet={wallet} nav={nav} /> + )) .add('New Address', () => ( <NewAddress store={store} invoice={invoice} info={info} /> )) diff --git a/test/unit/action/nav.spec.js b/test/unit/action/nav.spec.js index 5ff749de1..86e2b1c29 100644 --- a/test/unit/action/nav.spec.js +++ b/test/unit/action/nav.spec.js @@ -25,6 +25,13 @@ describe('Action Nav Unit Tests', () => { }); }); + describe('goSelectSeed()', () => { + it('should set correct route', () => { + nav.goSelectSeed(); + expect(store.route, 'to equal', 'SelectSeed'); + }); + }); + describe('goSeed()', () => { it('should set correct route', () => { nav.goSeed(); @@ -39,10 +46,10 @@ describe('Action Nav Unit Tests', () => { }); }); - describe.skip('goRestoreWallet()', () => { + describe('goRestoreSeed()', () => { it('should set correct route', () => { - nav.goRestoreWallet(); - expect(store.route, 'to equal', 'RestoreWallet'); + nav.goRestoreSeed(); + expect(store.route, 'to equal', 'RestoreSeed'); }); }); @@ -67,6 +74,13 @@ describe('Action Nav Unit Tests', () => { }); }); + describe('goRestorePassword()', () => { + it('should set correct route', () => { + nav.goRestorePassword(); + expect(store.route, 'to equal', 'RestorePassword'); + }); + }); + describe('goNewAddress()', () => { it('should set correct route', () => { nav.goNewAddress(); diff --git a/test/unit/action/wallet.spec.js b/test/unit/action/wallet.spec.js index 673606e56..d564d25d1 100644 --- a/test/unit/action/wallet.spec.js +++ b/test/unit/action/wallet.spec.js @@ -7,6 +7,7 @@ import NotificationAction from '../../../src/action/notification'; import * as logger from '../../../src/action/log'; import nock from 'nock'; import 'isomorphic-fetch'; +import { RECOVERY_WINDOW } from '../../../src/config'; describe('Action Wallet Unit Tests', () => { let store; @@ -87,6 +88,13 @@ describe('Action Wallet Unit Tests', () => { }); }); + describe('setRestoringWallet()', () => { + it('should clear attributes', () => { + wallet.setRestoringWallet({ restoring: true }); + expect(store.wallet.restoring, 'to equal', true); + }); + }); + describe('init()', () => { it('should generate seed and navigate to onboarding', async () => { grpc.sendUnlockerCommand.withArgs('GenSeed').resolves({ @@ -96,7 +104,7 @@ describe('Action Wallet Unit Tests', () => { expect(store.firstStart, 'to be', true); expect(store.seedMnemonic, 'to equal', 'foo bar'); expect(nav.goLoader, 'was called once'); - expect(nav.goSeed, 'was called once'); + expect(nav.goSelectSeed, 'was called once'); }); it('should navigate to password unlock if wallet already exists', async () => { @@ -228,6 +236,55 @@ describe('Action Wallet Unit Tests', () => { }); }); + describe('initRestoreWallet()', () => { + it('should clear attributes and navigate to view', () => { + store.wallet.restoreIndex = 42; + wallet.initRestoreWallet(); + expect(store.wallet.restoreSeed.length, 'to equal', 24); + expect(store.wallet.restoreIndex, 'to equal', 0); + expect(nav.goRestoreSeed, 'was called once'); + }); + }); + + describe('setRestoreSeed()', () => { + it('should clear attributes', () => { + wallet.setRestoreSeed({ word: 'foo', index: 1 }); + expect(store.wallet.restoreSeed[1], 'to equal', 'foo'); + }); + }); + + describe('initPrevRestorePage()', () => { + it('should navigate to select seed if restoreIndex < 3', () => { + store.wallet.restoreIndex = 2; + wallet.initPrevRestorePage(); + expect(nav.goSelectSeed, 'was called once'); + expect(store.wallet.restoreIndex, 'to equal', 2); + }); + + it('should decrement restoreIndex if greater than 2', async () => { + store.wallet.restoreIndex = 3; + wallet.initPrevRestorePage(); + expect(nav.goSelectSeed, 'was not called'); + expect(store.wallet.restoreIndex, 'to equal', 0); + }); + }); + + describe('initNextRestorePage()', () => { + it('should navigate to password screen if restoreIndex > 20', () => { + store.wallet.restoreIndex = 21; + wallet.initNextRestorePage(); + expect(nav.goRestorePassword, 'was called once'); + expect(store.wallet.restoreIndex, 'to equal', 21); + }); + + it('should increment restoreIndex if less than 21', async () => { + store.wallet.restoreIndex = 18; + wallet.initNextRestorePage(); + expect(nav.goRestorePassword, 'was not called'); + expect(store.wallet.restoreIndex, 'to equal', 21); + }); + }); + describe('initInitialDeposit()', () => { it('should navigate to new address screen if address is non-null', () => { store.walletAddress = 'non-null-addr'; @@ -260,6 +317,24 @@ describe('Action Wallet Unit Tests', () => { }); }); + describe('restoreWallet()', () => { + beforeEach(() => { + sandbox.stub(wallet, 'initWallet'); + }); + + it('calls initWallet with password and restoreSeed', async () => { + wallet.setPassword({ password: 'secret123' }); + const seed = Array(24).fill('foo'); + store.wallet.restoreSeed = seed; + await wallet.restoreWallet(); + expect(wallet.initWallet, 'was called with', { + walletPassword: 'secret123', + seedMnemonic: seed, + recoveryWindow: RECOVERY_WINDOW, + }); + }); + }); + describe('unlockWallet()', () => { it('should unlock wallet', async () => { grpc.sendUnlockerCommand.withArgs('UnlockWallet').resolves(); diff --git a/test/unit/computed/seed.spec.js b/test/unit/computed/seed.spec.js index 10fa4ee1e..70f249198 100644 --- a/test/unit/computed/seed.spec.js +++ b/test/unit/computed/seed.spec.js @@ -54,6 +54,25 @@ describe('Computed Seed Unit Tests', () => { } expect(store.seedVerifyCopy, 'to match', /^Type /); }); + + it('should set restore seed check attributes', () => { + store.wallet.restoreIndex = 3; + ComputedSeed(store); + expect(store.restoreIndexes.length, 'to equal', 24); + for (let i = 0; i < 24; i++) { + expect(store.restoreIndexes[i], 'to be greater than', 0); + expect(store.restoreIndexes[i], 'to be less than', 25); + if (i > 0) { + expect( + store.restoreIndexes[i], + 'to equal', + store.restoreIndexes[i - 1] + 1 + ); + } + } + expect(store.restoreVerifyIndexes, 'to equal', [4, 5, 6]); + expect(store.restoreVerifyCopy, 'to match', /^Type /); + }); }); describe('formatOrdinal()', () => {