diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a384fd7a..eb74603c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Changelog ### Features +- Implemented wallet recovery phrase verification ([PR 1565](https://github.com/input-output-hk/daedalus/pull/1565)) - Removed select dropdown arrow ([PR 1550](https://github.com/input-output-hk/daedalus/pull/1550)) - Implemented automated and manual update flows unification ([PR 1491](https://github.com/input-output-hk/daedalus/pull/1491)) - Updated behavior of system dialogs ([PR 1494](https://github.com/input-output-hk/daedalus/pull/1494)) diff --git a/cardano-sl-src.json b/cardano-sl-src.json index 2cf7a0252c..d534545dc0 100644 --- a/cardano-sl-src.json +++ b/cardano-sl-src.json @@ -1,7 +1,7 @@ { "url": "https://github.com/input-output-hk/cardano-sl", - "rev": "51ad7c0503b1c52a75a6eb36096c407934136468", - "date": "2019-08-10T03:35:47+00:00", - "sha256": "0cqaxk3k8i5z4q4b5na0pcln9fblglmn7900vp1xdzmw75pp1rrz", + "rev": "1a792d7cd0f0c93a0f0c28f66372bce3c3808dbd", + "date": "2019-09-25T010:53:54+00:00", + "sha256": "1vk71zn9bnkgkhgcyj59wzrp28crjwcd0lgnm013mhzpvxycgn61", "fetchSubmodules": false } diff --git a/features/node-update-notification.feature b/features/node-update-notification.feature index 09dabed758..aa685f69e6 100644 --- a/features/node-update-notification.feature +++ b/features/node-update-notification.feature @@ -8,8 +8,8 @@ Feature: Node Update Notification When I set next update version to "10" And I set next application version to "15" Then I should see the node update notification overlay - And Overlay should display "newer version" as available version and actions - + And Overlay should display "a newer version" as available version and actions + Scenario: Application version and next update version match When I set next application version to "15" And I set next update version to "15" @@ -31,4 +31,4 @@ Feature: Node Update Notification Then I should see the node update notification overlay And Overlay should display "0.14.0" as available version and actions When I click the accept update button - Then Daedalus should quit \ No newline at end of file + Then Daedalus should quit diff --git a/features/tests/e2e/helpers/wallets-helpers.js b/features/tests/e2e/helpers/wallets-helpers.js index ab9df8a927..4718d893bd 100644 --- a/features/tests/e2e/helpers/wallets-helpers.js +++ b/features/tests/e2e/helpers/wallets-helpers.js @@ -95,14 +95,17 @@ export const importWalletWithFunds = async ( const createWalletsAsync = async (table, context) => { const result = await context.client.executeAsync((wallets, done) => { + const mnemonics = {}; window.Promise.all( - wallets.map(wallet => - daedalus.api.ada.createWallet({ + wallets.map(wallet => { + const mnemonic = daedalus.utils.crypto.generateMnemonic(); + mnemonics[wallet.name] = mnemonic.split(' '); + return daedalus.api.ada.createWallet({ name: wallet.name, - mnemonic: daedalus.utils.crypto.generateMnemonic(), + mnemonic, spendingPassword: wallet.password || null, - }) - ) + }); + }) ) .then(() => daedalus.stores.wallets.walletsRequest @@ -110,7 +113,7 @@ const createWalletsAsync = async (table, context) => { .then(storeWallets => daedalus.stores.wallets .refreshWalletsData() - .then(() => done(storeWallets)) + .then(() => done({ storeWallets, mnemonics })) .catch(error => done(error)) ) .catch(error => done(error)) @@ -119,9 +122,14 @@ const createWalletsAsync = async (table, context) => { }, table); // Add or set the wallets for this scenario if (context.wallets != null) { - context.wallets.push(...result.value); + context.wallets.push(...result.value.storeWallets); } else { - context.wallets = result.value; + context.wallets = result.value.storeWallets; + } + if (context.mnemonics != null) { + context.mnemonics.push(...result.value.mnemonics); + } else { + context.mnemonics = result.value.mnemonics; } }; diff --git a/features/tests/e2e/steps/node-update-notification-steps.js b/features/tests/e2e/steps/node-update-notification-steps.js index 6bf8092028..46872cc3f8 100644 --- a/features/tests/e2e/steps/node-update-notification-steps.js +++ b/features/tests/e2e/steps/node-update-notification-steps.js @@ -23,12 +23,10 @@ When( this.client, SELECTORS.newAppVersionInfo ); - const [currentAppVersionInfo] = await getVisibleTextsForSelector( this.client, SELECTORS.currentAppVersionInfo ); - expect(newAppVersionInfo.replace('v ', '')).to.equal(nextVersion); expect(currentAppVersionInfo.replace('v ', '')).to.equal(currentAppVersion); this.client.waitForVisible('.AutomaticUpdateNotification_acceptButton'); diff --git a/features/tests/e2e/steps/wallet-recovery-phrase-verification-steps.js b/features/tests/e2e/steps/wallet-recovery-phrase-verification-steps.js new file mode 100644 index 0000000000..b155263adb --- /dev/null +++ b/features/tests/e2e/steps/wallet-recovery-phrase-verification-steps.js @@ -0,0 +1,94 @@ +import { Given, When, Then } from 'cucumber'; +import { expect } from 'chai'; +import { navigateTo } from '../helpers/route-helpers'; +import { + waitUntilWaletNamesEqual, + getNameOfActiveWalletInSidebar, +} from '../helpers/wallets-helpers'; + +const SETTINGS_PAGE_STATUS_SELECTOR = '.WalletRecoveryPhrase_validationStatus'; +const SETTINGS_PAGE_BUTTON_SELECTOR = `${SETTINGS_PAGE_STATUS_SELECTOR} .WalletRecoveryPhrase_validationStatusButton`; +const DIALOG_SELECTOR = '.Dialog_dialogWrapper'; +const DIALOG_CHECKBOX_SELECTOR = `${DIALOG_SELECTOR} .SimpleCheckbox_check`; +const DIALOG_CONTINUE_BUTTON_SELECTOR = `${DIALOG_SELECTOR} .SimpleButton_root`; +const DIALOG_SUCCESSFUL_SELECTOR = '.verification-successful'; +const DIALOG_UNSUCCESSFUL_SELECTOR = '.verification-unsuccessful'; +const DIALOG_VERIFY_AGAIN_BUTTON_SELECTOR = `${DIALOG_SELECTOR} button.attention`; +const DIALOG_CLOSE_BUTTON_SELECTOR = `${DIALOG_SELECTOR} .DialogCloseButton_component`; +const walletName = 'Wallet'; + +Given( + 'the last recovery phrase veryfication was done {int} days ago', + async function(daysAgo) { + await this.client.executeAsync((days, done) => { + const { id } = daedalus.stores.wallets.active; + const date = new Date(); + date.setDate(date.getDate() - days); + const recoveryPhraseVerificationDate = date.toISOString(); + const { updateWalletLocalData } = daedalus.actions.wallets; + updateWalletLocalData.once(done); + updateWalletLocalData.trigger({ + id, + recoveryPhraseVerificationDate, + }); + }, daysAgo); + } +); + +Then( + 'I should see a {string} recovery phrase veryfication feature', + async function(status) { + const statusClassname = `${SETTINGS_PAGE_STATUS_SELECTOR}${status}`; + return await this.client.waitForVisible(statusClassname); + } +); + +When(/^I click the recovery phrase veryfication button$/, function() { + return this.waitAndClick(SETTINGS_PAGE_BUTTON_SELECTOR); +}); + +When(/^I click the checkbox and Continue button$/, function() { + this.waitAndClick(DIALOG_CHECKBOX_SELECTOR); + return this.waitAndClick(DIALOG_CONTINUE_BUTTON_SELECTOR); +}); + +When(/^I enter the recovery phrase mnemonics correctly$/, async function() { + const recoveryPhrase = this.mnemonics[walletName].slice(); + await this.client.executeAsync((recoveryPhrase, done) => { + const { checkRecoveryPhrase } = daedalus.actions.walletBackup; + checkRecoveryPhrase.once(done); + checkRecoveryPhrase.trigger({ + recoveryPhrase, + }); + }, recoveryPhrase); +}); + +When(/^I enter the recovery phrase mnemonics incorrectly$/, async function() { + const incorrectRecoveryPhrase = [...this.mnemonics[walletName]]; + incorrectRecoveryPhrase[0] = 'wrong'; + await this.client.executeAsync((recoveryPhrase, done) => { + const { checkRecoveryPhrase } = daedalus.actions.walletBackup; + checkRecoveryPhrase.once(done); + checkRecoveryPhrase.trigger({ + recoveryPhrase, + }); + }, incorrectRecoveryPhrase); +}); + +When(/^I should see the confirmation dialog$/, async function() { + return this.client.waitForVisible(DIALOG_SUCCESSFUL_SELECTOR); +}); + +When(/^I should see the error dialog$/, async function() { + return this.client.waitForVisible(DIALOG_UNSUCCESSFUL_SELECTOR); +}); + +When(/^I should not see any dialog$/, async function() { + return this.client.waitForVisible(DIALOG_SELECTOR, null, true); +}); +When(/^I click the Verify again button$/, async function() { + return this.waitAndClick(DIALOG_VERIFY_AGAIN_BUTTON_SELECTOR); +}); +When(/^I click the close button$/, async function() { + return this.waitAndClick(DIALOG_CLOSE_BUTTON_SELECTOR); +}); diff --git a/features/wallet-settings-recovery-phrase-verification.feature b/features/wallet-settings-recovery-phrase-verification.feature new file mode 100644 index 0000000000..5d1b7f9816 --- /dev/null +++ b/features/wallet-settings-recovery-phrase-verification.feature @@ -0,0 +1,32 @@ +@e2e +Feature: Wallet Settings - Recovery Phrase Verification + + Background: + Given I have completed the basic setup + And I have the following wallets: + | name | + | Wallet | + + Scenario: Recovery phrase correctly verified + Given the last recovery phrase veryfication was done 400 days ago + And I am on the "Wallet" wallet "settings" screen + Then I should see a "Notification" recovery phrase veryfication feature + When I click the recovery phrase veryfication button + And I click the checkbox and Continue button + And I enter the recovery phrase mnemonics correctly + Then I should see the confirmation dialog + When I click the checkbox and Continue button + Then I should not see any dialog + And I should see a "Ok" recovery phrase veryfication feature + + Scenario: Recovery phrase incorrectly verified + Given the last recovery phrase veryfication was done 200 days ago + And I am on the "Wallet" wallet "settings" screen + Then I should see a "Warning" recovery phrase veryfication feature + When I click the recovery phrase veryfication button + And I click the checkbox and Continue button + And I enter the recovery phrase mnemonics incorrectly + Then I should see the error dialog + When I click the close button + Then I should not see any dialog + diff --git a/source/renderer/app/actions/wallet-backup-actions.js b/source/renderer/app/actions/wallet-backup-actions.js index 899ce0ff6d..49be9682f1 100644 --- a/source/renderer/app/actions/wallet-backup-actions.js +++ b/source/renderer/app/actions/wallet-backup-actions.js @@ -5,7 +5,9 @@ import Action from './lib/Action'; export default class WalletBackupActions { startWalletBackup: Action = new Action(); - initiateWalletBackup: Action<{ recoveryPhrase: string[] }> = new Action(); + initiateWalletBackup: Action<{ + recoveryPhrase: Array, + }> = new Action(); acceptPrivacyNoticeForWalletBackup: Action = new Action(); continueToRecoveryPhraseForWalletBackup: Action = new Action(); addWordToWalletBackupVerification: Action<{ @@ -18,4 +20,7 @@ export default class WalletBackupActions { restartWalletBackup: Action = new Action(); cancelWalletBackup: Action = new Action(); finishWalletBackup: Action = new Action(); + // Recovery phrase confirmation dialog actions + checkRecoveryPhrase: Action<{ recoveryPhrase: Array }> = new Action(); + resetRecoveryPhraseCheck: Action = new Action(); } diff --git a/source/renderer/app/actions/wallets-actions.js b/source/renderer/app/actions/wallets-actions.js index 00bcba2063..a549235c42 100644 --- a/source/renderer/app/actions/wallets-actions.js +++ b/source/renderer/app/actions/wallets-actions.js @@ -42,4 +42,6 @@ export default class WalletsActions { closeCertificateGeneration: Action = new Action(); setCertificateTemplate: Action<{ selectedTemplate: string }> = new Action(); finishCertificate: Action = new Action(); + updateWalletLocalData: Action = new Action(); + updateRecoveryPhraseVerificationDate: Action = new Action(); } diff --git a/source/renderer/app/api/api.js b/source/renderer/app/api/api.js index 7d1cad9708..06d34de5ba 100644 --- a/source/renderer/app/api/api.js +++ b/source/renderer/app/api/api.js @@ -45,6 +45,7 @@ import { createWallet } from './wallets/requests/createWallet'; import { restoreWallet } from './wallets/requests/restoreWallet'; import { updateWallet } from './wallets/requests/updateWallet'; import { getWalletUtxos } from './wallets/requests/getWalletUtxos'; +import { getWalletIdAndBalance } from './wallets/requests/getWalletIdAndBalance'; // utility functions import { @@ -118,6 +119,7 @@ import type { AdaWallet, AdaWallets, WalletUtxos, + WalletIdAndBalance, CreateWalletRequest, DeleteWalletRequest, RestoreWalletRequest, @@ -129,6 +131,8 @@ import type { ImportWalletFromFileRequest, UpdateWalletRequest, GetWalletUtxosRequest, + GetWalletIdAndBalanceRequest, + GetWalletIdAndBalanceResponse, } from './wallets/types'; // Common errors @@ -873,6 +877,36 @@ export default class AdaApi { } }; + getWalletIdAndBalance = async ( + request: GetWalletIdAndBalanceRequest + ): Promise => { + const { recoveryPhrase, getBalance } = request; + Logger.debug('AdaApi::getWalletIdAndBalance called', { + parameters: { getBalance }, + }); + try { + const response: GetWalletIdAndBalanceResponse = await getWalletIdAndBalance( + this.config, + { + recoveryPhrase, + getBalance, + } + ); + Logger.debug('AdaApi::getWalletIdAndBalance success', { response }); + const { walletId, balance } = response; + return { + walletId, + balance: + balance !== null // If balance is "null" it means we didn't fetch it - getBalance was false + ? new BigNumber(balance).dividedBy(LOVELACES_PER_ADA) + : null, + }; + } catch (error) { + Logger.error('AdaApi::getWalletIdAndBalance error', { error }); + throw new GenericApiError(); + } + }; + testReset = async (): Promise => { Logger.debug('AdaApi::testReset called'); try { @@ -1035,6 +1069,7 @@ const _createWalletFromServerData = action( hasSpendingPassword, spendingPasswordLastUpdate, syncState, + createdAt, } = data; return new Wallet({ @@ -1046,6 +1081,7 @@ const _createWalletFromServerData = action( passwordUpdateDate: new Date(`${spendingPasswordLastUpdate}Z`), syncState, isLegacy: false, + createdAt, }); } ); diff --git a/source/renderer/app/api/utils/localStorage.js b/source/renderer/app/api/utils/localStorage.js index 0f9a59b058..7687d22ed7 100644 --- a/source/renderer/app/api/utils/localStorage.js +++ b/source/renderer/app/api/utils/localStorage.js @@ -4,11 +4,22 @@ const store = global.electronStore; +export type WalletLocalData = { + id: string, + recoveryPhraseVerificationDate?: ?Date, + creationDate?: ?Date, +}; + +export type WalletsLocalData = { + [key: string]: WalletLocalData, +}; + type StorageKeys = { USER_LOCALE: string, TERMS_OF_USE_ACCEPTANCE: string, THEME: string, DATA_LAYER_MIGRATION_ACCEPTANCE: string, + WALLETS: string, }; /** @@ -25,6 +36,7 @@ export default class LocalStorageApi { TERMS_OF_USE_ACCEPTANCE: `${NETWORK}-TERMS-OF-USE-ACCEPTANCE`, THEME: `${NETWORK}-THEME`, DATA_LAYER_MIGRATION_ACCEPTANCE: `${NETWORK}-DATA-LAYER-MIGRATION-ACCEPTANCE`, + WALLETS: `${NETWORK}-WALLETS`, }; } @@ -146,6 +158,70 @@ export default class LocalStorageApi { } catch (error) {} // eslint-disable-line }); + getWalletsLocalData = (): Promise => + new Promise((resolve, reject) => { + try { + const walletsLocalData = store.get(this.storageKeys.WALLETS); + if (!walletsLocalData) return resolve({}); + return resolve(walletsLocalData); + } catch (error) { + return reject(error); + } + }); + + getWalletLocalData = (walletId: string): Promise => + new Promise((resolve, reject) => { + try { + const walletData = store.get(`${this.storageKeys.WALLETS}.${walletId}`); + if (!walletData) { + resolve({ + id: walletId, + }); + } + return resolve(walletData); + } catch (error) { + return reject(error); + } + }); + + setWalletLocalData = (walletData: WalletLocalData): Promise => + new Promise((resolve, reject) => { + try { + const walletId = walletData.id; + store.set(`${this.storageKeys.WALLETS}.${walletId}`, walletData); + return resolve(); + } catch (error) { + return reject(error); + } + }); + + updateWalletLocalData = (updatedWalletData: Object): Promise => + new Promise(async (resolve, reject) => { + const walletId = updatedWalletData.id; + const currentWalletData = await this.getWalletLocalData(walletId); + const walletData = Object.assign( + {}, + currentWalletData, + updatedWalletData + ); + try { + store.set(`${this.storageKeys.WALLETS}.${walletId}`, walletData); + return resolve(walletData); + } catch (error) { + return reject(error); + } + }); + + unsetWalletLocalData = (walletId: string): Promise => + new Promise((resolve, reject) => { + try { + store.delete(`${this.storageKeys.WALLETS}.${walletId}`); + return resolve(); + } catch (error) { + return reject(error); + } + }); + reset = async () => { await this.unsetUserLocale(); await this.unsetTermsOfUseAcceptance(); diff --git a/source/renderer/app/api/wallets/requests/getWalletIdAndBalance.js b/source/renderer/app/api/wallets/requests/getWalletIdAndBalance.js new file mode 100644 index 0000000000..c05dbcb9d0 --- /dev/null +++ b/source/renderer/app/api/wallets/requests/getWalletIdAndBalance.js @@ -0,0 +1,23 @@ +// @flow +import type { RequestConfig } from '../../common/types'; +import type { + GetWalletIdAndBalanceRequest, + GetWalletIdAndBalanceResponse, +} from '../types'; +import { request } from '../../utils/request'; + +export const getWalletIdAndBalance = ( + config: RequestConfig, + { recoveryPhrase, getBalance }: GetWalletIdAndBalanceRequest +): Promise => + request( + { + method: 'POST', + path: '/api/internal/calculate_mnemonic', + ...config, + }, + { + read_balance: getBalance, + }, + recoveryPhrase + ); diff --git a/source/renderer/app/api/wallets/types.js b/source/renderer/app/api/wallets/types.js index 30f82918be..5e3b25a078 100644 --- a/source/renderer/app/api/wallets/types.js +++ b/source/renderer/app/api/wallets/types.js @@ -1,6 +1,8 @@ // @flow +import BigNumber from 'bignumber.js'; + export type AdaWallet = { - createdAt: string, + createdAt: Date, syncState: WalletSyncState, balance: number, hasSpendingPassword: boolean, @@ -48,6 +50,11 @@ export type WalletUtxos = { }, }; +export type WalletIdAndBalance = { + walletId: string, + balance: ?BigNumber, +}; + // req/res Wallet types export type CreateWalletRequest = { name: string, @@ -69,6 +76,16 @@ export type GetWalletUtxosRequest = { walletId: string, }; +export type GetWalletIdAndBalanceRequest = { + recoveryPhrase: Array, + getBalance: boolean, +}; + +export type GetWalletIdAndBalanceResponse = { + walletId: string, + balance: ?number, +}; + export type RestoreWalletRequest = { recoveryPhrase: string, walletName: string, diff --git a/source/renderer/app/assets/images/recovery-phrase-verification-notification.inline.svg b/source/renderer/app/assets/images/recovery-phrase-verification-notification.inline.svg new file mode 100644 index 0000000000..d7d9b879df --- /dev/null +++ b/source/renderer/app/assets/images/recovery-phrase-verification-notification.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/source/renderer/app/assets/images/recovery-phrase-verification-ok.inline.svg b/source/renderer/app/assets/images/recovery-phrase-verification-ok.inline.svg new file mode 100644 index 0000000000..714fd3a335 --- /dev/null +++ b/source/renderer/app/assets/images/recovery-phrase-verification-ok.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/source/renderer/app/assets/images/recovery-phrase-verification-warning.inline.svg b/source/renderer/app/assets/images/recovery-phrase-verification-warning.inline.svg new file mode 100644 index 0000000000..3f5acb401d --- /dev/null +++ b/source/renderer/app/assets/images/recovery-phrase-verification-warning.inline.svg @@ -0,0 +1,3 @@ + + + diff --git a/source/renderer/app/components/navigation/NavButton.js b/source/renderer/app/components/navigation/NavButton.js index 2af1e15786..186daac2f7 100755 --- a/source/renderer/app/components/navigation/NavButton.js +++ b/source/renderer/app/components/navigation/NavButton.js @@ -11,16 +11,18 @@ type Props = { isActive: boolean, onClick: Function, className?: string, + hasNotification?: boolean, }; @observer export default class NavButton extends Component { render() { - const { isActive, icon, onClick, className } = this.props; + const { isActive, icon, onClick, className, hasNotification } = this.props; const componentClasses = classnames([ className, styles.component, isActive ? styles.active : styles.normal, + hasNotification ? styles.hasNotification : null, ]); const iconClasses = classnames([ styles.icon, diff --git a/source/renderer/app/components/navigation/NavButton.scss b/source/renderer/app/components/navigation/NavButton.scss index 0b31557e94..8307559de8 100755 --- a/source/renderer/app/components/navigation/NavButton.scss +++ b/source/renderer/app/components/navigation/NavButton.scss @@ -7,6 +7,23 @@ justify-content: center; text-align: center; width: 100%; + + &.hasNotification { + .container { + position: relative; + &:after { + background: var(--theme-button-attention-background-color); + border-radius: 50%; + content: ''; + height: 8px; + transform: translate(4px, -4px); + width: 8px; + } + .icon { + margin-left: 8px; + } + } + } } .container { diff --git a/source/renderer/app/components/navigation/NavDropdown.js b/source/renderer/app/components/navigation/NavDropdown.js index 54a81ff7cc..8650e7a8fb 100644 --- a/source/renderer/app/components/navigation/NavDropdown.js +++ b/source/renderer/app/components/navigation/NavDropdown.js @@ -1,6 +1,7 @@ // @flow import React, { Component } from 'react'; import { observer } from 'mobx-react'; +import classnames from 'classnames'; import { Select } from 'react-polymorph/lib/components/Select'; import { SelectSkin } from './NavSelectSkin'; @@ -16,14 +17,27 @@ type Props = { isActive: boolean, options?: Array<{ value: number | string, label: string }>, onChange: Function, + hasNotification?: boolean, }; @observer export default class NavDropdown extends Component { render() { - const { label, icon, isActive, onChange, options, activeItem } = this.props; + const { + label, + icon, + isActive, + onChange, + options, + activeItem, + hasNotification, + } = this.props; + const componentStyles = classnames([ + styles.component, + hasNotification ? styles.hasNotification : null, + ]); return ( -
+