From 0412030c1b2cc0e724dc2ccdb5d41a73cc9800d5 Mon Sep 17 00:00:00 2001 From: Empowerful <45366397+Empowerful@users.noreply.github.com> Date: Thu, 25 Apr 2019 17:34:33 -0400 Subject: [PATCH] POC: Configure two Web Wallet applications (Root Document & Main Process) to coexist. --- .../package.json | 3 +- .../src/IPC/Middleware.js | 70 +++ .../src/IPC/index.js | 29 ++ .../src/data/auth/actionTypes.js | 1 + .../src/data/auth/actions.js | 6 + .../src/data/auth/sagaRegister.js | 5 +- .../src/data/auth/sagas.js | 27 +- .../src/data/auth/sagas.spec.js | 8 +- .../src/data/logs/reducers.js | 1 + .../src/data/rootSaga.js | 13 +- .../src/scenes/Settings/General/index.js | 4 - .../src/store/index.js | 226 +++++---- .../webpack.config.dev.js | 16 +- .../src/network/api/http.js | 3 +- .../src/network/api/index.js | 3 +- .../src/network/walletApi.js | 13 +- .../src/redux/kvStore/eth/sagaRegister.js | 4 +- .../src/redux/kvStore/eth/sagas.js | 38 +- .../src/redux/kvStore/root/sagas.js | 51 +- .../src/redux/kvStore/sagaRegister.js | 24 +- .../src/redux/kvStore/sagas.js | 26 +- .../src/redux/kvStore/xlm/sagaRegister.js | 4 +- .../src/redux/kvStore/xlm/sagas.js | 24 +- .../src/redux/rootSaga.js | 4 +- .../blockchain-wallet-v4/src/redux/sagas.js | 6 +- .../src/redux/wallet/actionTypes.js | 1 + .../src/redux/wallet/actions.js | 3 + .../src/redux/wallet/sagas.js | 9 +- .../src/redux/walletSync/middleware.js | 108 ++--- .../src/redux/walletSync/middleware.spec.js | 14 +- .../blockchain-wallet-v4/src/remote/index.js | 2 + .../src/types/HDWallet.js | 16 +- .../src/types/KVStoreEntry.js | 13 +- .../src/types/Serializer.js | 4 +- .../blockchain-wallet-v4/src/types/Wallet.js | 75 +-- .../blockchain-wallet-v4/src/utils/eth.js | 24 +- .../blockchain-wallet-v4/src/utils/xlm.js | 6 +- main-process/packages/web-microkernel | 1 + main-process/yarn.lock | 11 +- .../package.json | 3 +- .../src/IPC/Exports.js | 61 +++ .../src/IPC/Middleware.js | 135 ++++++ .../src/IPC/index.js | 45 ++ .../src/data/auth/actionTypes.js | 1 + .../src/data/auth/actions.js | 6 + .../src/data/auth/sagaRegister.js | 1 + .../src/data/auth/sagas.js | 61 +-- .../src/data/auth/sagas.spec.js | 8 +- .../src/data/logs/reducers.js | 1 + .../src/index.html | 88 +++- .../src/layouts/Security/Header/index.js | 53 +++ .../src/layouts/Security/Header/index.spec.js | 14 + .../src/layouts/Security/index.js | 114 +++++ .../AdvancedSecurity/PairingCode/index.js | 89 ++++ .../AdvancedSecurity/WalletId/index.js | 49 ++ .../SecurityCenter/AdvancedSecurity/index.js | 4 + .../src/scenes/app.js | 3 +- .../src/store/index.js | 231 +++++---- .../webpack.config.dev.js | 3 +- .../src/redux/kvStore/root/sagas.js | 4 +- .../src/redux/kvStore/xlm/sagas.js | 3 +- .../__snapshots__/walletReducers.spec.js.snap | 86 ++++ .../src/redux/wallet/actionTypes.js | 1 + .../src/redux/wallet/actions.js | 3 + .../src/redux/wallet/reducers.js | 32 +- .../src/redux/wallet/walletReducers.spec.js | 17 + .../blockchain-wallet-v4/src/remote/index.js | 2 + .../src/types/Serializer.js | 14 +- packages/web-microkernel/package.json | 9 + .../web-microkernel/src/RealmConnection.js | 441 ++++++++++++++++++ .../src/RealmConnection.test.js | 406 ++++++++++++++++ packages/web-microkernel/src/index.js | 4 + packages/web-microkernel/src/lodash-es | 1 + .../web-microkernel/src/multiplexRealm.js | 39 ++ .../src/multiplexRealm.test.js | 103 ++++ packages/web-microkernel/wallaby.js | 7 + packages/web-microkernel/yarn.lock | 8 + yarn.lock | 11 +- 78 files changed, 2486 insertions(+), 571 deletions(-) create mode 100644 main-process/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js create mode 100644 main-process/packages/blockchain-wallet-v4-frontend/src/IPC/index.js create mode 120000 main-process/packages/web-microkernel create mode 100644 packages/blockchain-wallet-v4-frontend/src/IPC/Exports.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/IPC/index.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.spec.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/layouts/Security/index.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/PairingCode/index.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/WalletId/index.js create mode 100644 packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap create mode 100644 packages/web-microkernel/package.json create mode 100644 packages/web-microkernel/src/RealmConnection.js create mode 100644 packages/web-microkernel/src/RealmConnection.test.js create mode 100644 packages/web-microkernel/src/index.js create mode 120000 packages/web-microkernel/src/lodash-es create mode 100644 packages/web-microkernel/src/multiplexRealm.js create mode 100644 packages/web-microkernel/src/multiplexRealm.test.js create mode 100644 packages/web-microkernel/wallaby.js create mode 100644 packages/web-microkernel/yarn.lock diff --git a/main-process/packages/blockchain-wallet-v4-frontend/package.json b/main-process/packages/blockchain-wallet-v4-frontend/package.json index 9aea4db5df6..f14c3edf4ef 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/package.json +++ b/main-process/packages/blockchain-wallet-v4-frontend/package.json @@ -84,10 +84,11 @@ }, "dependencies": { "@blockchain-com/components": "5.1.1", - "@ledgerhq/hw-app-eth": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-app-btc": "4.30.0", + "@ledgerhq/hw-app-eth": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-app-str": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-transport-u2f": "4.31.0", + "@nodeguy/channel": "0.6.5", "awesome-phonenumber": "2.2.6", "base-64": "0.1.0", "bignumber.js": "8.0.1", diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js b/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js new file mode 100644 index 00000000000..c8c2f59befe --- /dev/null +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js @@ -0,0 +1,70 @@ +import * as router from 'connected-react-router' +import * as R from 'ramda' + +import * as coreTypes from 'blockchain-wallet-v4/src/redux/actionTypes' +import * as types from '../data/actionTypes' + +const alreadyForwarded = ({ meta }) => meta && meta.forwarded + +const dispatchToBoth = ({ rootDocumentDispatch, next }, action) => { + if (!alreadyForwarded(action)) { + rootDocumentDispatch(action) + } + + next(action) +} + +const dispatchToRootDocument = ({ rootDocumentDispatch }, action) => { + rootDocumentDispatch(action) +} + +const tag = action => ({ + ...action, + meta: { ...action.meta, forwarded: true } +}) + +const handlers = { + // This requires the GUID. + [coreTypes.data.misc.FETCH_LOGS]: dispatchToRootDocument, + + // This requires the GUID. + [coreTypes.settings.FETCH_SETTINGS]: dispatchToRootDocument, + + // Tell the Root Document to merge our wrapper with its own. + [coreTypes.wallet.MERGE_WRAPPER]: dispatchToRootDocument, + + // Inform the root document about routing changes so that it can switch which + // application is displayed. + [router.LOCATION_CHANGE]: dispatchToBoth, + + // Tell the root document to reload itself when we do. + [types.auth.LOGOUT]: dispatchToBoth, + + // This requires the GUID. + [types.modules.settings.UPDATE_LANGUAGE]: dispatchToRootDocument +} + +export default ({ actionsChannel, rootDocumentDispatch }) => store => { + // Now that we have access to the store, dispatch stored actions from the Root + // Document to it. + actionsChannel.forEach(store.dispatch) + + return next => action => { + const { type } = action + + const context = { + rootDocumentDispatch: R.pipe( + tag, + rootDocumentDispatch + ), + next, + store + } + + if (type in handlers) { + return handlers[type](context, action) + } else { + return next(action) + } + } +} diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/index.js b/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/index.js new file mode 100644 index 00000000000..1d566ee98ea --- /dev/null +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/IPC/index.js @@ -0,0 +1,29 @@ +import Channel from '@nodeguy/channel' + +import { serializer } from 'blockchain-wallet-v4/src/types' +import Middleware from './Middleware' +import * as kernel from '../../../web-microkernel/src' + +export default async ({ input, output, outputOrigin }) => { + // We need to export a function for dispatching actions from the Root + // Document before the store is created so use a channel to save them until + // the store is ready. + const actionsChannel = Channel() + + const connection = await kernel.RealmConnection({ + exports: { dispatch: actionsChannel.push }, + input, + output, + outputOrigin, + reviver: serializer.reviver + }) + + return { + connection, + + middleware: Middleware({ + actionsChannel, + rootDocumentDispatch: connection.imports.dispatch + }) + } +} diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js index 0de985229bb..14e453788a5 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js @@ -3,6 +3,7 @@ export const DEAUTHORIZE_BROWSER = 'DEAUTHORIZE_BROWSER' export const LOGIN = 'LOGIN' export const LOGIN_FAILURE = 'LOGIN_FAILURE' export const LOGIN_LOADING = 'LOGIN_LOADING' +export const LOGIN_ROUTINE = 'LOGIN_ROUTINE' export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' export const LOGOUT = 'LOGOUT' export const LOGOUT_CLEAR_REDUX_STORE = 'LOGOUT_CLEAR_REDUX_STORE' diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js index 63600f6a11a..f1845c62e56 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js @@ -7,6 +7,12 @@ export const login = (guid, password, code, sharedKey, mobileLogin) => ({ payload: { guid, password, code, sharedKey, mobileLogin } }) export const loginLoading = () => ({ type: AT.LOGIN_LOADING }) + +export const loginRoutine = (mobileLogin = false, firstLogin = false) => ({ + type: AT.LOGIN_ROUTINE, + payload: { firstLogin, mobileLogin } +}) + export const loginSuccess = () => ({ type: AT.LOGIN_SUCCESS, payload: {} }) export const loginFailure = err => ({ type: AT.LOGIN_FAILURE, diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js index f45371a6e1a..2144c42a7e3 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js @@ -2,12 +2,13 @@ import { takeLatest } from 'redux-saga/effects' import * as AT from './actionTypes' import sagas from './sagas' -export default ({ api, coreSagas }) => { - const authSagas = sagas({ api, coreSagas }) +export default (...args) => { + const authSagas = sagas(...args) return function* authSaga () { yield takeLatest(AT.DEAUTHORIZE_BROWSER, authSagas.deauthorizeBrowser) yield takeLatest(AT.LOGIN, authSagas.login) + yield takeLatest(AT.LOGIN_ROUTINE, authSagas.loginRoutineSaga) yield takeLatest(AT.LOGOUT, authSagas.logout) yield takeLatest( AT.LOGOUT_CLEAR_REDUX_STORE, diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js index 9b8247a6e8c..2d556e15c6d 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js @@ -28,7 +28,7 @@ export const emailMismatch2faErrorMessage = export const wrongCaptcha2faErrorMessage = 'Error: Captcha Code Incorrect' export const wrongAuthCodeErrorMessage = 'Authentication code is incorrect' -export default ({ api, coreSagas }) => { +export default ({ api, coreSagas, rootDocument }) => { const upgradeWallet = function*() { try { let password = yield call(promptForSecondPassword) @@ -92,15 +92,18 @@ export default ({ api, coreSagas }) => { if (userFlowSupported) yield put(actions.modules.profile.signIn()) } - const loginRoutineSaga = function*(mobileLogin, firstLogin) { + const loginRoutineSaga = function*({ payload: { mobileLogin, firstLogin } }) { try { // If needed, the user should upgrade its wallet before being able to open the wallet const isHdWallet = yield select(selectors.core.wallet.isHdWallet) if (!isHdWallet) { yield call(upgradeWalletSaga) } - yield put(actions.auth.authenticate()) - yield call(coreSagas.kvStore.root.fetchRoot, askSecondPasswordEnhancer) + + yield call(coreSagas.kvStore.root.fetchRoot, { + askSecondPasswordEnhancer + }) + // If there was no ethereum metadata kv store entry, we need to create one and that requires the second password. yield call( coreSagas.kvStore.ethereum.fetchMetadataEthereum, @@ -118,19 +121,13 @@ export default ({ api, coreSagas }) => { yield put(actions.middleware.webSocket.eth.startSocket()) yield put(actions.middleware.webSocket.xlm.startStreams()) yield put(actions.router.push('/home')) - yield call(coreSagas.settings.fetchSettings) + yield put(actions.core.settings.fetchSettings()) yield call(coreSagas.data.xlm.fetchLedgerDetails) yield call(coreSagas.data.xlm.fetchData) yield call(authNabu) yield call(upgradeAddressLabelsSaga) yield put(actions.auth.loginSuccess()) yield put(actions.auth.startLogoutTimer()) - // store guid in cache for future logins - const guid = yield select(selectors.core.wallet.getGuid) - yield put(actions.cache.guidEntered(guid)) - // reset auth type and clear previous login form state - yield put(actions.auth.setAuthType(0)) - yield put(actions.form.destroy('login')) // set payload language to settings language const language = yield select(selectors.preferences.getLanguage) yield put(actions.modules.settings.updateLanguage(language)) @@ -222,7 +219,7 @@ export default ({ api, coreSagas }) => { password, code }) - yield call(loginRoutineSaga, mobileLogin) + yield put(actions.auth.loginRoutine(mobileLogin)) } catch (error) { const initialError = prop('initial_error', error) const authRequired = prop('authorization_required', error) @@ -245,7 +242,7 @@ export default ({ api, coreSagas }) => { session, password }) - yield call(loginRoutineSaga, mobileLogin) + yield put(actions.auth.loginRoutine(mobileLogin)) } catch (error) { if (error && error.auth_type > 0) { yield put(actions.auth.setAuthType(error.auth_type)) @@ -334,7 +331,7 @@ export default ({ api, coreSagas }) => { yield put(actions.alerts.displayInfo(C.CREATE_WALLET_INFO)) yield call(coreSagas.wallet.createWalletSaga, action.payload) yield put(actions.alerts.displaySuccess(C.REGISTER_SUCCESS)) - yield call(loginRoutineSaga, false, true) + yield put(actions.auth.loginRoutine(false, true)) yield put(actions.auth.registerSuccess()) } catch (e) { yield put(actions.auth.registerFailure()) @@ -349,7 +346,7 @@ export default ({ api, coreSagas }) => { yield put(actions.alerts.displayInfo(C.RESTORE_WALLET_INFO)) yield call(coreSagas.wallet.restoreWalletSaga, action.payload) yield put(actions.alerts.displaySuccess(C.RESTORE_SUCCESS)) - yield call(loginRoutineSaga, false, true) + yield put(actions.auth.loginRoutine(false, true)) yield put(actions.auth.restoreSuccess()) } catch (e) { yield put(actions.auth.restoreFailure()) diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js index b4d73337244..1d501d2dfea 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js @@ -93,11 +93,11 @@ describe('authSagas', () => { }) }) - it('should call login routine', () => { + it('should put login routine', () => { const { mobileLogin } = payload saga .next() - .call(loginRoutineSaga, mobileLogin) + .put(actions.auth.loginRoutineSaga(mobileLogin)) .next() .isDone() }) @@ -185,9 +185,9 @@ describe('authSagas', () => { }) }) - it('should call login routine', () => { + it('should put login routine', () => { const { mobileLogin } = payload - saga.next().call(loginRoutineSaga, mobileLogin) + saga.next().put(actions.auth.loginRoutineSaga(mobileLogin)) }) it('should follow 2FA flow on auth error', () => { diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js index c905bf90105..119fef2b1b3 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js @@ -18,6 +18,7 @@ const logger = (state = INITIAL_STATE, action) => { switch (type) { case AT.LOG_ERROR_MSG: { + console.error(payload) return insert(0, createLog('ERROR', payload), state) } case AT.LOG_INFO_MSG: { diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/rootSaga.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/rootSaga.js index 85c6d3f32ba..7d19e72edb2 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/rootSaga.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/rootSaga.js @@ -59,23 +59,30 @@ export default function* rootSaga ({ btcSocket, ethSocket, ratesSocket, + rootDocument, networks, options }) { - const coreSagas = coreSagasFactory({ api, networks, options }) + + const coreSagas = coreSagasFactory({ + api, + networks, + options, + rootDocument + }) yield all([ call(welcomeSaga), fork(alerts), fork(analytics({ api })), - fork(auth({ api, coreSagas })), + fork(auth({ api, coreSagas, rootDocument })), fork(components({ api, coreSagas, networks, options })), fork(modules({ api, coreSagas, networks })), fork(preferences()), fork(goals({ api })), fork(wallet({ coreSagas })), fork(middleware({ api, bchSocket, btcSocket, ethSocket, ratesSocket })), - fork(coreRootSagaFactory({ api, networks, options })), + fork(coreRootSagaFactory({ api, networks, options, rootDocument })), fork(router()), call(languageInitSaga) ]) diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/scenes/Settings/General/index.js b/main-process/packages/blockchain-wallet-v4-frontend/src/scenes/Settings/General/index.js index cfae104eab7..a41ae687195 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/scenes/Settings/General/index.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/scenes/Settings/General/index.js @@ -5,10 +5,8 @@ import { FormattedMessage } from 'react-intl' import { Banner, Text } from 'blockchain-info-components' import About from './About' -import PairingCode from './PairingCode' import PrivacyPolicy from './PrivacyPolicy' import TermsOfService from './TermsOfService' -import WalletId from './WalletId' const Wrapper = styled.section` padding: 30px; @@ -32,8 +30,6 @@ const General = () => { /> - - diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js b/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js index 2efff0316cf..976baa3038b 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js @@ -18,6 +18,8 @@ import { } from 'blockchain-wallet-v4/src/network' import { serializer } from 'blockchain-wallet-v4/src/types' import { actions, rootSaga, rootReducer, selectors } from 'data' +import IPC from '../IPC' + import { autoDisconnection, streamingXlm, @@ -27,8 +29,11 @@ import { webSocketRates } from '../middleware' +import * as kernel from '../../../web-microkernel/src' + const devToolsConfig = { maxAge: 1000, + name: `Main Process`, serialize: serializer, actionsBlacklist: [ // '@@redux-form/INITIALIZE', @@ -43,118 +48,133 @@ const devToolsConfig = { ] } -const configureStore = () => { +const configureStore = async () => { const history = createHashHistory() const sagaMiddleware = createSagaMiddleware() const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(devToolsConfig) : compose - const walletPath = 'wallet.payload' const kvStorePath = 'wallet.kvstore' const isAuthenticated = selectors.auth.isAuthenticated - return fetch('/Resources/wallet-options-v4.json') - .then(res => res.json()) - .then(options => { - const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' - // TODO: deprecate when wallet-options-v4 is updated on prod - const socketUrl = head(options.domains.webSocket.split('/inv')) - const horizonUrl = options.domains.horizon - const btcSocket = new Socket({ - options, - url: `${socketUrl}/inv` - }) - const bchSocket = new Socket({ - options, - url: `${socketUrl}/bch/inv` - }) - const ethSocket = new Socket({ - options, - url: `${socketUrl}/eth/inv` - }) - const ratesSocket = new ApiSocket({ - options, - url: `${socketUrl}/nabu-gateway/markets/quotes`, - maxReconnects: 3 - }) - const xlmStreamingService = new HorizonStreamingService({ - url: horizonUrl - }) - const getAuthCredentials = () => - selectors.modules.profile.getAuthCredentials(store.getState()) - const reauthenticate = () => - store.dispatch(actions.modules.profile.signIn()) - const networks = { - btc: Bitcoin.networks[options.platforms.web.btc.config.network], - bch: BitcoinCash.networks[options.platforms.web.btc.config.network], - bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], - eth: options.platforms.web.eth.config.network, - xlm: options.platforms.web.xlm.config.network - } - const api = createWalletApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) - const persistWhitelist = ['session', 'preferences', 'cache'] - - // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) - const store = createStore( - connectRouter(history)( - persistCombineReducers( - { - getStoredState: getStoredStateMigrateV4({ - whitelist: persistWhitelist - }), - key: 'root', - storage, - whitelist: persistWhitelist - }, - rootReducer - ) - ), - composeEnhancers( - applyMiddleware( - sagaMiddleware, - routerMiddleware(history), - coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), - webSocketBtc(btcSocket), - webSocketBch(bchSocket), - webSocketEth(ethSocket), - streamingXlm(xlmStreamingService, api), - webSocketRates(ratesSocket), - coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), - autoDisconnection() - ) - ) + const options = await (await fetch( + '/Resources/wallet-options-v4.json' + )).json() + + const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' + // TODO: deprecate when wallet-options-v4 is updated on prod + const socketUrl = head(options.domains.webSocket.split('/inv')) + const horizonUrl = options.domains.horizon + const btcSocket = new Socket({ + options, + url: `${socketUrl}/inv` + }) + const bchSocket = new Socket({ + options, + url: `${socketUrl}/bch/inv` + }) + const ethSocket = new Socket({ + options, + url: `${socketUrl}/eth/inv` + }) + const ratesSocket = new ApiSocket({ + options, + url: `${socketUrl}/nabu-gateway/markets/quotes`, + maxReconnects: 3 + }) + const xlmStreamingService = new HorizonStreamingService({ + url: horizonUrl + }) + + const { + connection: rootDocumentConnection, + middleware: IPCmiddleware + } = await IPC({ + input: kernel.multiplexRealm({ tag: `realms` }, window), + output: kernel.multiplexRealm({ tag: `realms` }, window.parent), + outputOrigin: options.domains.rootDocument + }) + + rootDocumentConnection.addEventListener(`error`, console.error) + + const getAuthCredentials = () => + selectors.modules.profile.getAuthCredentials(store.getState()) + const reauthenticate = () => store.dispatch(actions.modules.profile.signIn()) + const networks = { + btc: Bitcoin.networks[options.platforms.web.btc.config.network], + bch: BitcoinCash.networks[options.platforms.web.btc.config.network], + bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], + eth: options.platforms.web.eth.config.network, + xlm: options.platforms.web.xlm.config.network + } + + const api = createWalletApi({ + axiosAdapter: kernel.sanitizeFunction(rootDocumentConnection.imports.axios), + options, + apiKey, + getAuthCredentials, + reauthenticate, + networks + }) + + const persistWhitelist = ['session', 'preferences', 'cache'] + + // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) + const store = createStore( + connectRouter(history)( + persistCombineReducers( + { + getStoredState: getStoredStateMigrateV4({ + whitelist: persistWhitelist + }), + key: 'root', + storage, + whitelist: persistWhitelist + }, + rootReducer ) - const persistor = persistStore(store, null) - - sagaMiddleware.run(rootSaga, { - api, - bchSocket, - btcSocket, - ethSocket, - ratesSocket, - networks, - options - }) - - // expose globals here - window.createTestXlmAccounts = () => { - store.dispatch(actions.core.data.xlm.createTestAccounts()) - } - - store.dispatch(actions.goals.defineGoals()) - - return { - store, - history, - persistor - } - }) + ), + composeEnhancers( + applyMiddleware( + IPCmiddleware, + sagaMiddleware, + routerMiddleware(history), + coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), + webSocketBtc(btcSocket), + webSocketBch(bchSocket), + webSocketEth(ethSocket), + streamingXlm(xlmStreamingService, api), + webSocketRates(ratesSocket), + coreMiddleware.walletSync({ isAuthenticated }), + autoDisconnection() + ) + ) + ) + const persistor = persistStore(store, null) + + sagaMiddleware.run(rootSaga, { + api, + bchSocket, + btcSocket, + ethSocket, + ratesSocket, + rootDocument: rootDocumentConnection.imports, + networks, + options + }) + + // expose globals here + window.createTestXlmAccounts = () => { + store.dispatch(actions.core.data.xlm.createTestAccounts()) + } + + store.dispatch(actions.goals.defineGoals()) + + return { + store, + history, + persistor + } } export default configureStore diff --git a/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js b/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js index ca6f7010fc1..1fa64945a99 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js @@ -17,13 +17,14 @@ const iSignThisDomain = let envConfig = {} let manifestCacheBust = new Date().getTime() +const port = 8082 let sslEnabled = process.env.DISABLE_SSL ? false : fs.existsSync(PATHS.sslConfig + '/key.pem') && fs.existsSync(PATHS.sslConfig + '/cert.pem') let localhostUrl = sslEnabled - ? 'https://localhost:8080' - : 'http://localhost:8080' + ? `https://localhost:${port}` + : `http://localhost:${port}` try { envConfig = require(PATHS.envConfig + `/${process.env.NODE_ENV}` + '.js') @@ -61,7 +62,7 @@ module.exports = { app: [ '@babel/polyfill', 'react-hot-loader/patch', - 'webpack-dev-server/client?http://localhost:8080', + `webpack-dev-server/client?http://localhost:${port}`, 'webpack/hot/only-dev-server', PATHS.src + '/index.js' ] @@ -192,7 +193,7 @@ module.exports = { key: sslEnabled ? fs.readFileSync(PATHS.sslConfig + '/key.pem', 'utf8') : '', - port: 8080, + port, hot: true, historyApiFallback: true, before(app) { @@ -200,6 +201,7 @@ module.exports = { // combine wallet options base with custom environment config mockWalletOptions.domains = { root: envConfig.ROOT_URL, + rootDocument: `http://localhost:8080`, api: envConfig.API_DOMAIN, webSocket: envConfig.WEB_SOCKET_URL, walletHelper: envConfig.WALLET_HELPER_DOMAIN, @@ -257,13 +259,13 @@ module.exports = { "style-src 'self' 'unsafe-inline'", `frame-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} ${ envConfig.ROOT_URL - } https://magic.veriff.me https://localhost:8080 http://localhost:8080`, + } https://magic.veriff.me https://localhost:${port} http://localhost:${port}`, `child-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} blob:`, [ 'connect-src', "'self'", - 'ws://localhost:8080', - 'wss://localhost:8080', + `ws://localhost:${port}`, + `wss://localhost:${port}`, 'wss://api.ledgerwallet.com', 'wss://ws.testnet.blockchain.info/inv', envConfig.WEB_SOCKET_URL, diff --git a/main-process/packages/blockchain-wallet-v4/src/network/api/http.js b/main-process/packages/blockchain-wallet-v4/src/network/api/http.js index 18238b40022..65ff8b28e37 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/api/http.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/api/http.js @@ -5,7 +5,7 @@ import { prop, path, pathOr, merge } from 'ramda' axios.defaults.withCredentials = false axios.defaults.timeout = Infinity -export default ({ apiKey }) => { +export default ({ axiosAdapter, apiKey }) => { const encodeData = (data, contentType) => { const defaultData = { api_code: apiKey, @@ -39,6 +39,7 @@ export default ({ apiKey }) => { ...options }) => axios({ + adapter: axiosAdapter, url: `${url}${endPoint}`, method, data: encodeData(data, contentType), diff --git a/main-process/packages/blockchain-wallet-v4/src/network/api/index.js b/main-process/packages/blockchain-wallet-v4/src/network/api/index.js index 92401c06321..34edbeefc6f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/api/index.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/api/index.js @@ -20,13 +20,14 @@ import httpService from './http' import apiAuthorize from './apiAuthorize' export default ({ + axiosAdapter, options, apiKey, getAuthCredentials, reauthenticate, networks } = {}) => { - const http = httpService({ apiKey }) + const http = httpService({ axiosAdapter, apiKey }) const authorizedHttp = apiAuthorize(http, getAuthCredentials, reauthenticate) const apiUrl = options.domains.api const horizonUrl = options.domains.horizon diff --git a/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js b/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js index dd9b47fd49a..43e9cbdb657 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js @@ -20,18 +20,9 @@ import { futurizeP } from 'futurize' import createApi from './api' import * as Coin from '../coinSelection/coin.js' -const createWalletApi = ( - { options, apiKey, getAuthCredentials, reauthenticate, networks } = {}, - returnType -) => { +const createWalletApi = (options, returnType) => { // //////////////////////////////////////////////////////////////// - const ApiPromise = createApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) + const ApiPromise = createApi(options) const eitherToTask = e => e.fold(Task.rejected, Task.of) const taskToPromise = t => new Promise((resolve, reject) => t.fork(reject, resolve)) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagaRegister.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagaRegister.js index d92c6963c80..b282085e8d9 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagaRegister.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagaRegister.js @@ -2,8 +2,8 @@ import { takeLatest } from 'redux-saga/effects' import * as AT from './actionTypes' import sagas from './sagas' -export default ({ api, networks }) => { - const kvStoreEthereumSagas = sagas({ api, networks }) +export default (...args) => { + const kvStoreEthereumSagas = sagas(...args) return function* coreKvStoreEthSaga () { yield takeLatest( diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagas.js index a5409aa1ce3..828ce67666a 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/eth/sagas.js @@ -1,4 +1,4 @@ -import { head, prop, isNil, isEmpty } from 'ramda' +import { curry, head, prop, isNil, isEmpty } from 'ramda' import { call, put, select } from 'redux-saga/effects' import { set } from 'ramda-lens' import * as A from './actions' @@ -7,28 +7,31 @@ import { KVStoreEntry } from '../../../types' import { getMetadataXpriv } from '../root/selectors' import { derivationMap, ETHEREUM } from '../config' import * as eth from '../../../utils/eth' -import { getMnemonic } from '../../wallet/selectors' + +import { getPbkdf2Iterations, getSharedKey } from '../../wallet/selectors' import { callTask } from '../../../utils/functional' -export default ({ api, networks } = {}) => { - const deriveAccount = function*(password) { - try { - const obtainMnemonic = state => getMnemonic(state, password) - const mnemonicT = yield select(obtainMnemonic) - const mnemonic = yield callTask(mnemonicT) - const defaultIndex = 0 - const addr = eth.deriveAddress(mnemonic, defaultIndex) +export default ({ api, networks, rootDocument } = {}) => { + const deriveAccount = function*(secondPassword) { + const defaultIndex = 0 - return { defaultIndex, addr } - } catch (e) { - throw new Error( - '[NOT IMPLEMENTED] MISSING_SECOND_PASSWORD in core.createEthereum saga' - ) + const credentials = { + iterations: yield select(getPbkdf2Iterations), + secondPassword, + sharedKey: yield select(getSharedKey) } + + const addr = yield call(eth.deriveAddress, { + deriveBIP32Key: curry(rootDocument.deriveBIP32Key)(credentials), + index: defaultIndex + }) + + return { defaultIndex, addr } } const createEthereum = function*({ kv, password }) { const { defaultIndex, addr } = yield call(deriveAccount, password) + const ethereum = { has_seen: true, default_account_idx: defaultIndex, @@ -50,7 +53,10 @@ export default ({ api, networks } = {}) => { } const transitionFromLegacy = function*({ newkv, password }) { - const { defaultIndex, addr } = yield call(deriveAccount, password) + const { defaultIndex, addr } = yield call( + rootDocument.deriveEthereumAccount, + password + ) const defaultAccount = Map(newkv.value.ethereum.accounts[defaultIndex]) newkv.value.ethereum.legacy_account = defaultAccount.toJS() newkv.value.ethereum.accounts[defaultIndex].addr = addr diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js index a9e32db86a0..3cfa11d0250 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js @@ -1,18 +1,13 @@ import { call, put, select } from 'redux-saga/effects' import { prop, compose, isNil } from 'ramda' import * as A from './actions' -import BIP39 from 'bip39' import { KVStoreEntry } from '../../../types' -import { - getMnemonic, - getGuid, - getMainPassword, - getSharedKey -} from '../../wallet/selectors' +import { getPbkdf2Iterations, getSharedKey } from '../../wallet/selectors' + const taskToPromise = t => new Promise((resolve, reject) => t.fork(reject, resolve)) -export default ({ api, networks }) => { +export default ({ api, networks, rootDocument }) => { const callTask = function*(task) { return yield call( compose( @@ -23,39 +18,39 @@ export default ({ api, networks }) => { } const createRoot = function*({ password }) { try { - const obtainMnemonic = state => getMnemonic(state, password) - const mnemonicT = yield select(obtainMnemonic) - const mnemonic = yield call(() => taskToPromise(mnemonicT)) - const seedHex = BIP39.mnemonicToEntropy(mnemonic) - const getMetadataNode = compose( - KVStoreEntry.deriveMetadataNode, - KVStoreEntry.getMasterHDNode(networks.btc) - ) - const metadataNode = getMetadataNode(seedHex) - const metadata = metadataNode.toBase58() + const credentials = { + iterations: yield select(getPbkdf2Iterations), + secondPassword: password, + sharedKey: yield select(getSharedKey) + } + + const metadata = yield call(rootDocument.deriveBIP32Key, credentials, { + network: networks.btc, + path: `m/${KVStoreEntry.metadataPurpose}'` + }) + yield put(A.updateMetadataRoot({ metadata })) } catch (e) { throw new Error('create root Metadata :: Error decrypting mnemonic') } } - const fetchRoot = function*(secondPasswordSagaEnhancer) { + const fetchRoot = function*({ askSecondPasswordEnhancer }) { try { - const guid = yield select(getGuid) const sharedKey = yield select(getSharedKey) - const mainPassword = yield select(getMainPassword) yield put(A.fetchMetadataRootLoading()) - const kv = KVStoreEntry.fromCredentials( - guid, - sharedKey, - mainPassword, - networks.btc - ) + const entropy = yield call(rootDocument.credentialsEntropy, { sharedKey }) + + const kv = yield call(KVStoreEntry.fromEntropy, { + entropy, + network: networks.btc + }) + const newkv = yield callTask(api.fetchKVStore(kv)) yield put(A.fetchMetadataRootSuccess(newkv)) if (isNil(prop('metadata', newkv.value))) { // no metadata node saved - const createRootenhanced = secondPasswordSagaEnhancer(createRoot) + const createRootenhanced = askSecondPasswordEnhancer(createRoot) yield call(createRootenhanced, {}) } } catch (e) { diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagaRegister.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagaRegister.js index 98296907ae2..4f78afd342e 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagaRegister.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagaRegister.js @@ -12,17 +12,17 @@ import contacts from './contacts/sagaRegister' import lockbox from './lockbox/sagaRegister' import userCredentials from './userCredentials/sagaRegister' -export default ({ api, networks }) => +export default (...args) => function* coreKvStoreSaga () { - yield fork(whatsNew({ api, networks })) - yield fork(ethereum({ api, networks })) - yield fork(bch({ api, networks })) - yield fork(btc({ api, networks })) - yield fork(bsv({ api, networks })) - yield fork(xlm({ api, networks })) - yield fork(shapeShift({ api, networks })) - yield fork(buySell({ api, networks })) - yield fork(contacts({ api, networks })) - yield fork(lockbox({ api, networks })) - yield fork(userCredentials({ api, networks })) + yield fork(whatsNew(...args)) + yield fork(ethereum(...args)) + yield fork(bch(...args)) + yield fork(btc(...args)) + yield fork(bsv(...args)) + yield fork(xlm(...args)) + yield fork(shapeShift(...args)) + yield fork(buySell(...args)) + yield fork(contacts(...args)) + yield fork(lockbox(...args)) + yield fork(userCredentials(...args)) } diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagas.js index a307d9f2e16..39ceb26262c 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/sagas.js @@ -11,17 +11,17 @@ import userCredentials from './userCredentials/sagas' import shapeShift from './shapeShift/sagas' import xlm from './xlm/sagas' -export default ({ api, networks }) => ({ - bch: bch({ api, networks }), - btc: btc({ api, networks }), - bsv: bsv({ api, networks }), - ethereum: eth({ api, networks }), - root: root({ api, networks }), - lockbox: lockbox({ api, networks }), - buySell: buySell({ api, networks }), - whatsNew: whatsNew({ api, networks }), - contacts: contacts({ api, networks }), - shapeShift: shapeShift({ api, networks }), - userCredentials: userCredentials({ api, networks }), - xlm: xlm({ api, networks }) +export default (...args) => ({ + bch: bch(...args), + btc: btc(...args), + bsv: bsv(...args), + ethereum: eth(...args), + root: root(...args), + lockbox: lockbox(...args), + buySell: buySell(...args), + whatsNew: whatsNew(...args), + contacts: contacts(...args), + shapeShift: shapeShift(...args), + userCredentials: userCredentials(...args), + xlm: xlm(...args) }) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagaRegister.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagaRegister.js index d5915069376..142c9b337c6 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagaRegister.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagaRegister.js @@ -2,8 +2,8 @@ import { takeLatest } from 'redux-saga/effects' import * as AT from './actionTypes' import sagas from './sagas' -export default ({ api, networks }) => { - const kvStoreXlmSagas = sagas({ api, networks }) +export default (...args) => { + const kvStoreXlmSagas = sagas(...args) return function* coreKvStoreXlmSaga () { yield takeLatest(AT.FETCH_METADATA_XLM, kvStoreXlmSagas.fetchMetadataXlm) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js index 58369d9a087..632b107d70a 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js @@ -1,20 +1,29 @@ import { call, put, select } from 'redux-saga/effects' -import { isNil, isEmpty } from 'ramda' +import { curry, isNil, isEmpty } from 'ramda' import { set } from 'ramda-lens' import * as A from './actions' import { KVStoreEntry } from '../../../types' import { getMetadataXpriv } from '../root/selectors' import { derivationMap, XLM } from '../config' -import { getMnemonic } from '../../wallet/selectors' import { getKeyPair } from '../../../utils/xlm' import { callTask } from '../../../utils/functional' +import { getPbkdf2Iterations, getSharedKey } from '../../wallet/selectors' -export default ({ api, networks } = {}) => { +export default ({ api, networks, rootDocument } = {}) => { const createXlm = function*({ kv, password }) { try { - const mnemonicT = yield select(getMnemonic, password) - const mnemonic = yield callTask(mnemonicT) - const keypair = getKeyPair(mnemonic) + const credentials = { + iterations: yield select(getPbkdf2Iterations), + secondPassword: password, + sharedKey: yield select(getSharedKey) + } + + const keypair = yield call(getKeyPair, { + deriveSLIP10ed25519Key: curry(rootDocument.deriveSLIP10ed25519Key)( + credentials + ) + }) + const xlm = { default_account_idx: 0, accounts: [ @@ -43,7 +52,8 @@ export default ({ api, networks } = {}) => { yield put(A.fetchMetadataXlmLoading()) const newkv = yield callTask(api.fetchKVStore(kv)) if (isNil(newkv.value) || isEmpty(newkv.value)) { - yield call(secondPasswordSagaEnhancer(createXlm), { kv }) + const newkv = yield call(secondPasswordSagaEnhancer(createXlm), { kv }) + yield put(A.createMetadataXlm(newkv)) } else { yield put(A.fetchMetadataXlmSuccess(newkv)) } diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/rootSaga.js b/main-process/packages/blockchain-wallet-v4/src/redux/rootSaga.js index ccfc887e071..02f6f91105e 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/rootSaga.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/rootSaga.js @@ -5,11 +5,11 @@ import walletOptions from './walletOptions/sagaRegister' import settings from './settings/sagaRegister' import wallet from './wallet/sagaRegister' -export default ({ api, networks, options }) => +export default ({ api, networks, options, rootDocument }) => function* coreSaga () { yield all([ fork(data({ api, options, networks })), - fork(kvStore({ api, networks })), + fork(kvStore({ api, networks, rootDocument })), fork(walletOptions({ api, options })), fork(settings({ api })), fork(wallet({ api, networks })) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/sagas.js index 23a574f8cc0..6a882cd34bf 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/sagas.js @@ -5,11 +5,11 @@ import walletOptions from './walletOptions/sagas' import kvStore from './kvStore/sagas' import payment from './payment/sagas' -export default ({ api, networks, options }) => ({ +export default ({ api, networks, options, rootDocument }) => ({ data: data({ api, options, networks }), settings: settings({ api }), - wallet: wallet({ api, networks }), + wallet: wallet({ api, networks, rootDocument }), walletOptions: walletOptions({ api }), - kvStore: kvStore({ api, networks }), + kvStore: kvStore({ api, networks, rootDocument }), payment: payment({ api, options }) }) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js index 1e71c3b27bd..6211d6c7718 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js @@ -1,4 +1,5 @@ // setters +export const MERGE_WRAPPER = '@CORE.MERGE_WRAPPER' export const SET_WRAPPER = '@CORE.SET_WRAPPER' export const REFRESH_WRAPPER = '@CORE.REFRESH_WRAPPER' export const SET_MAIN_PASSWORD = '@CORE.SET_MAIN_PASSWORD' diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js index 624d77e3563..4e6c7b28f3c 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js @@ -1,6 +1,9 @@ import * as T from './actionTypes' // setters + +export const mergeWrapper = payload => ({ type: T.MERGE_WRAPPER, payload }) + export const setWrapper = payload => ({ type: T.SET_WRAPPER, payload: payload diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js index efd085340c9..c30796e348e 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js @@ -30,7 +30,7 @@ import { generateMnemonic } from '../../walletCrypto' const taskToPromise = t => new Promise((resolve, reject) => t.fork(reject, resolve)) -export default ({ api, networks }) => { +export default ({ api, networks, rootDocument }) => { const runTask = function*(task, setActionCreator) { let result = yield call( compose( @@ -80,7 +80,12 @@ export default ({ api, networks }) => { let wrapper = yield select(S.getWrapper) let nextWrapper = Wrapper.traverseWallet( Task.of, - Wallet.newHDAccount(label, password, networks.btc), + Wallet.newHDAccount({ + deriveBIP32Key: rootDocument.deriveBIP32Key, + label, + network: networks.btc, + secondPassword: password + }), wrapper ) yield call(runTask, nextWrapper, A.wallet.setWrapper) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js index 6e24f37c6ee..87c2e913bd3 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js @@ -1,9 +1,7 @@ -import { futurizeP } from 'futurize' -import Task from 'data.task' +import * as A from '../actions' + import { compose, - assoc, - join, curry, range, keysIn, @@ -15,9 +13,8 @@ import { } from 'ramda' import { networks } from 'bitcoinjs-lib' -import * as A from '../actions' import * as T from '../actionTypes' -import { Wrapper, Wallet, HDAccount } from '../../types' +import { Wallet, HDAccount } from '../../types' import * as selectors from '../selectors' /** @@ -93,81 +90,44 @@ export const getWalletAddresses = async (state, api) => { return activeAddresses.concat(uniq(hdAddresses.concat(unusedAddresses))) } +export const shouldSync = ({ + actionType, + newAuthenticated, + newWallet, + oldAuthenticated, + oldWallet +}) => + actionType === T.walletSync.FORCE_SYNC || + (oldAuthenticated && + newAuthenticated && + actionType !== T.wallet.SET_PAYLOAD_CHECKSUM && + actionType !== T.wallet.REFRESH_WRAPPER && + // Easily know when to sync, because of ✨immutable✨ data + // the initial_state check could be done against full payload state + oldWallet !== newWallet) + /** * Wallet sync middleware * Calls sync on special conditions * * TODO: refactor to sagas, VERY painful to test/write mocks */ -const walletSync = ({ - isAuthenticated, - api -} = {}) => store => next => action => { - const prevState = store.getState() - const prevWallet = selectors.wallet.getWrapper(prevState) - const wasAuth = isAuthenticated(prevState) +const walletSync = ({ isAuthenticated } = {}) => store => next => action => { + const oldState = store.getState() const result = next(action) - - const state = store.getState() - const nextWallet = selectors.wallet.getWrapper(state) - const syncPubKeys = selectors.wallet.shouldSyncPubKeys(state) - const isAuth = isAuthenticated(state) - const promiseToTask = futurizeP(Task) - - // Easily know when to sync, because of ✨immutable✨ data - // the initial_state check could be done against full payload state - - const handleChecksum = encrypted => { - const checksum = Wrapper.computeChecksum(encrypted) - compose( - store.dispatch, - A.wallet.setPayloadChecksum - )(checksum) - return encrypted - } - - const sync = async () => { - let encryptedWallet = Wrapper.toEncJSON(nextWallet) - if (syncPubKeys) { - /** - * To get notifications working you have to add list of lookahead addresses - * For each of the wallet's accounts - */ - try { - const addresses = await getWalletAddresses(state, api) - encryptedWallet = encryptedWallet.map( - assoc('active', join('|', addresses)) - ) - } catch (error) { - return store.dispatch(A.walletSync.syncError(error)) - } - } - return encryptedWallet - .map(handleChecksum) - .chain(promiseToTask(api.savePayload)) - .fork( - compose( - store.dispatch, - A.walletSync.syncError - ), - compose( - store.dispatch, - A.walletSync.syncSuccess - ) - ) - } - - switch (true) { - case action.type === T.walletSync.FORCE_SYNC: - case wasAuth && - isAuth && - action.type !== T.wallet.SET_PAYLOAD_CHECKSUM && - action.type !== T.wallet.REFRESH_WRAPPER && - prevWallet !== nextWallet: - sync() - break - default: - break + const newState = store.getState() + const newWallet = selectors.wallet.getWrapper(newState) + + if ( + shouldSync({ + actionType: action.type, + newAuthenticated: isAuthenticated(newState), + newWallet, + oldAuthenticated: isAuthenticated(oldState), + oldWallet: selectors.wallet.getWrapper(oldState) + }) + ) { + store.dispatch(A.wallet.mergeWrapper(newWallet)) } return result diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js index ac3655dbde9..0852d2de82f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js @@ -1,7 +1,8 @@ import { map, range } from 'ramda' import { addressLookaheadCount, - getHDAccountAddressPromises + getHDAccountAddressPromises, + shouldSync } from './middleware' import { getReceiveAddress } from '../../types/HDAccount' import { getAccountXpub } from '../wallet/selectors' @@ -80,3 +81,14 @@ describe('getHDAccountAddressPromises', () => { return promise }) }) + +it(`shouldSync if the wallet changes`, () => { + expect( + shouldSync({ + newAuthenticated: true, + newWallet: 43, + oldAuthenticated: true, + oldWallet: 42 + }) + ).toEqual(true) +}) diff --git a/main-process/packages/blockchain-wallet-v4/src/remote/index.js b/main-process/packages/blockchain-wallet-v4/src/remote/index.js index 845f7bcb156..5f45170cc5e 100755 --- a/main-process/packages/blockchain-wallet-v4/src/remote/index.js +++ b/main-process/packages/blockchain-wallet-v4/src/remote/index.js @@ -1,3 +1,5 @@ +// https://medium.com/@jaumepernas/your-data-is-loading-1425c6b76bf0 + import { taggedSum } from 'daggy' const Remote = taggedSum('Remote', { diff --git a/main-process/packages/blockchain-wallet-v4/src/types/HDWallet.js b/main-process/packages/blockchain-wallet-v4/src/types/HDWallet.js index d1581d73a57..218884f86eb 100755 --- a/main-process/packages/blockchain-wallet-v4/src/types/HDWallet.js +++ b/main-process/packages/blockchain-wallet-v4/src/types/HDWallet.js @@ -84,10 +84,20 @@ export const deriveAccountNodeAtIndex = (seedHex, index, network) => { .deriveHardened(index) } -export const generateAccount = curry((index, label, network, seedHex) => { - let node = deriveAccountNodeAtIndex(seedHex, index, network) +export const generateAccount = async ({ + deriveBIP32Key, + index, + label, + network +}) => { + const key = await deriveBIP32Key({ + network, + path: `m/44'/0'/${index}'` + }) + + const node = Bitcoin.HDNode.fromBase58(key) return HDAccount.fromJS(HDAccount.js(label, node, null)) -}) +} // encrypt :: Number -> String -> String -> HDWallet -> Task Error HDWallet export const encrypt = curry((iterations, sharedKey, password, hdWallet) => { diff --git a/main-process/packages/blockchain-wallet-v4/src/types/KVStoreEntry.js b/main-process/packages/blockchain-wallet-v4/src/types/KVStoreEntry.js index 444c23365ce..e7dc97aedd4 100755 --- a/main-process/packages/blockchain-wallet-v4/src/types/KVStoreEntry.js +++ b/main-process/packages/blockchain-wallet-v4/src/types/KVStoreEntry.js @@ -61,13 +61,12 @@ export const fromKeys = (entryECKey, encKeyBuffer, typeId) => { }) } -export const fromCredentials = curry((guid, sharedKey, password, network) => { - const entropy = crypto.sha256(Buffer.from(guid + sharedKey + password)) +export const fromEntropy = ({ entropy, network }) => { const d = BigInteger.fromBuffer(entropy) const key = new Bitcoin.ECPair(d, null, { network }) const enc = key.d.toBuffer(32) return fromKeys(key, enc) -}) +} export const getMasterHDNode = curry((network, seedHex) => { const mnemonic = BIP39.entropyToMnemonic(seedHex) @@ -75,6 +74,14 @@ export const getMasterHDNode = curry((network, seedHex) => { return Bitcoin.HDNode.fromSeedBuffer(masterhex, network) }) +// BIP 43 purpose needs to be 31 bit or less. For lack of a BIP number +// we take the first 31 bits of the SHA256 hash of a reverse domain. +export const metadataPurpose = + crypto + .sha256('info.blockchain.metadata') + .slice(0, 4) + .readUInt32BE(0) & 0x7fffffff // 510742 + export const deriveMetadataNode = masterHDNode => { // BIP 43 purpose needs to be 31 bit or less. For lack of a BIP number // we take the first 31 bits of the SHA256 hash of a reverse domain. diff --git a/main-process/packages/blockchain-wallet-v4/src/types/Serializer.js b/main-process/packages/blockchain-wallet-v4/src/types/Serializer.js index 2eb76750fc7..d872f9f8e05 100755 --- a/main-process/packages/blockchain-wallet-v4/src/types/Serializer.js +++ b/main-process/packages/blockchain-wallet-v4/src/types/Serializer.js @@ -30,7 +30,9 @@ const serializer = { return value }, reviver: function (key, value) { - if ( + if (value && value.type === 'Buffer') { + return Buffer.from(value.data) + } else if ( typeof value === 'object' && value !== null && '__serializedType__' in value diff --git a/main-process/packages/blockchain-wallet-v4/src/types/Wallet.js b/main-process/packages/blockchain-wallet-v4/src/types/Wallet.js index 489144cf36c..d68a6cae22f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/types/Wallet.js +++ b/main-process/packages/blockchain-wallet-v4/src/types/Wallet.js @@ -6,18 +6,7 @@ import Maybe from 'data.maybe' import Bitcoin from 'bitcoinjs-lib' import memoize from 'fast-memoize' import BIP39 from 'bip39' -import { - compose, - curry, - map, - is, - pipe, - __, - concat, - split, - isNil, - flip -} from 'ramda' +import { compose, curry, map, is, pipe, __, concat, split, isNil } from 'ramda' import { traversed, traverseOf, over, view, set } from 'ramda-lens' import * as crypto from '../walletCrypto' import { shift, shiftIProp } from './util' @@ -293,29 +282,45 @@ export const newHDWallet = curry((mnemonic, password, wallet) => { ) }) -// newHDAccount :: String -> String? -> Wallet -> Task Error Wallet -export const newHDAccount = curry((label, password, network, wallet) => { - let hdWallet = HDWalletList.selectHDWallet(selectHdWallets(wallet)) - let index = hdWallet.accounts.size - let appendAccount = curry((w, account) => { - let accountsLens = compose( - hdWallets, - HDWalletList.hdwallet, - HDWallet.accounts - ) - let accountWithIndex = set(HDAccount.index, index, account) - return over(accountsLens, accounts => accounts.push(accountWithIndex), w) - }) - return applyCipher( - wallet, - password, - flip(crypto.decryptSecPass), - hdWallet.seedHex - ) - .map(HDWallet.generateAccount(index, label, network)) - .chain(applyCipher(wallet, password, HDAccount.encrypt)) - .map(appendAccount(wallet)) -}) +const promiseToTask = promise => + new Task((reject, resolve) => promise.then(resolve, reject)) + +// newHDAccount :: Object -> Wallet -> Task Error Wallet +export const newHDAccount = curry( + ({ deriveBIP32Key, label, secondPassword, network }, wallet) => { + let hdWallet = HDWalletList.selectHDWallet(selectHdWallets(wallet)) + let index = hdWallet.accounts.size + let appendAccount = curry((w, account) => { + let accountsLens = compose( + hdWallets, + HDWalletList.hdwallet, + HDWallet.accounts + ) + let accountWithIndex = set(HDAccount.index, index, account) + return over(accountsLens, accounts => accounts.push(accountWithIndex), w) + }) + + const credentials = { + iterations: selectIterations(wallet), + secondPassword, + sharedKey: selectSharedKey(wallet) + } + + return validateSecondPwd(Task.of, Task.rejected)(secondPassword, wallet) + .chain(() => + promiseToTask( + HDWallet.generateAccount({ + deriveBIP32Key: curry(deriveBIP32Key)(credentials), + index, + label, + network + }) + ) + ) + .chain(applyCipher(wallet, secondPassword, HDAccount.encrypt)) + .map(appendAccount(wallet)) + } +) // setLegacyAddressLabel :: String -> String -> Wallet -> Wallet export const setLegacyAddressLabel = curry((address, label, wallet) => { diff --git a/main-process/packages/blockchain-wallet-v4/src/utils/eth.js b/main-process/packages/blockchain-wallet-v4/src/utils/eth.js index c0523556df8..35e97961e4f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/utils/eth.js +++ b/main-process/packages/blockchain-wallet-v4/src/utils/eth.js @@ -1,7 +1,5 @@ import * as Exchange from '../exchange' import { prop, path } from 'ramda' -import BIP39 from 'bip39' -import Bitcoin from 'bitcoinjs-lib' import EthHd from 'ethereumjs-wallet/hdkey' import EthTx from 'ethereumjs-tx' import EthUtil from 'ethereumjs-util' @@ -13,19 +11,15 @@ import BigNumber from 'bignumber.js' export const isValidAddress = address => /^0x[a-fA-F0-9]{40}$/.test(address) /** - * @param {string} mnemonic + * @param {function} deriveBIP32Key * @param {integer} index */ -export const getPrivateKey = (mnemonic, index) => { - const seed = BIP39.mnemonicToSeed(mnemonic) - const account = Bitcoin.HDNode.fromSeedBuffer(seed) - .deriveHardened(44) - .deriveHardened(60) - .deriveHardened(0) - .derive(0) - .derive(index) - .toBase58() - return EthHd.fromExtendedKey(account) +export const getPrivateKey = async ({ deriveBIP32Key, index }) => { + const key = await deriveBIP32Key({ + path: `m/44'/60'/0'/0/${index}` + }) + + return EthHd.fromExtendedKey(key) .getWallet() .getPrivateKey() } @@ -47,8 +41,8 @@ const deriveChildLegacy = (index, seed) => { export const privateKeyToAddress = pk => EthUtil.toChecksumAddress(EthUtil.privateToAddress(pk).toString('hex')) -export const deriveAddress = (mnemonic, index) => - privateKeyToAddress(getPrivateKey(mnemonic, index)) +export const deriveAddress = async (...args) => + privateKeyToAddress(await getPrivateKey(...args)) export const deriveAddressFromXpub = xpub => { const ethPublic = EthHd.fromExtendedKey(xpub) diff --git a/main-process/packages/blockchain-wallet-v4/src/utils/xlm.js b/main-process/packages/blockchain-wallet-v4/src/utils/xlm.js index 820dadc079e..34c13e7d3e5 100755 --- a/main-process/packages/blockchain-wallet-v4/src/utils/xlm.js +++ b/main-process/packages/blockchain-wallet-v4/src/utils/xlm.js @@ -48,9 +48,7 @@ export const decodeXlmURI = uri => { return { address: destination, amount, memo, note: msg } } -export const getKeyPair = mnemonic => { - const seed = BIP39.mnemonicToSeed(mnemonic) - const seedHex = seed.toString('hex') - const masterKey = ed25519.derivePath("m/44'/148'/0'", seedHex) +export const getKeyPair = async ({ deriveSLIP10ed25519Key }) => { + const masterKey = await deriveSLIP10ed25519Key({ path: `m/44'/148'/0'` }) return StellarSdk.Keypair.fromRawEd25519Seed(masterKey.key) } diff --git a/main-process/packages/web-microkernel b/main-process/packages/web-microkernel new file mode 120000 index 00000000000..0c8cc3cca44 --- /dev/null +++ b/main-process/packages/web-microkernel @@ -0,0 +1 @@ +../../packages/web-microkernel \ No newline at end of file diff --git a/main-process/yarn.lock b/main-process/yarn.lock index b454ce5e8cc..91b3f70b20c 100644 --- a/main-process/yarn.lock +++ b/main-process/yarn.lock @@ -1419,6 +1419,13 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@nodeguy/channel@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@nodeguy/channel/-/channel-0.6.5.tgz#c04938f443bacaf8abcbe574ca7c12b802f07808" + integrity sha512-etReylPZCfyWU0dgs4qPAOCWPZ8bFaFNNYJTZ894FMZNHOastw6Bb2F42iDnfWBf0ONkDRWLzEt2YABn//iHZQ== + dependencies: + setimmediate "1.0.5" + "@nodelib/fs.stat@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" @@ -10122,7 +10129,7 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash-es@^4.17.10: +lodash-es@4.17.11, lodash-es@^4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== @@ -13874,7 +13881,7 @@ set-value@^2.0.0: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@1.0.5, setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= diff --git a/packages/blockchain-wallet-v4-frontend/package.json b/packages/blockchain-wallet-v4-frontend/package.json index 9aea4db5df6..f14c3edf4ef 100644 --- a/packages/blockchain-wallet-v4-frontend/package.json +++ b/packages/blockchain-wallet-v4-frontend/package.json @@ -84,10 +84,11 @@ }, "dependencies": { "@blockchain-com/components": "5.1.1", - "@ledgerhq/hw-app-eth": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-app-btc": "4.30.0", + "@ledgerhq/hw-app-eth": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-app-str": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-transport-u2f": "4.31.0", + "@nodeguy/channel": "0.6.5", "awesome-phonenumber": "2.2.6", "base-64": "0.1.0", "bignumber.js": "8.0.1", diff --git a/packages/blockchain-wallet-v4-frontend/src/IPC/Exports.js b/packages/blockchain-wallet-v4-frontend/src/IPC/Exports.js new file mode 100644 index 00000000000..442420b1ca7 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/IPC/Exports.js @@ -0,0 +1,61 @@ +import axios from 'axios' +import BIP39 from 'bip39' +import Bitcoin from 'bitcoinjs-lib' +import * as ed25519 from 'ed25519-hd-key' + +import { + getDefaultHDWallet, + getGuid, + getMainPassword +} from 'blockchain-wallet-v4/src/redux/wallet/selectors' + +import { HDWallet } from 'blockchain-wallet-v4/src/types' +import * as crypto from 'blockchain-wallet-v4/src/walletCrypto' +import * as kernel from '../../../web-microkernel/src' + +export default ({ store }) => { + const credentialsEntropy = ({ sharedKey }) => { + const state = store.getState() + const guid = getGuid(state) + const password = getMainPassword(state) + return crypto.sha256(Buffer.from(guid + sharedKey + password)) + } + + const getSeed = ({ iterations, secondPassword, sharedKey }) => { + const state = store.getState() + const candidate = HDWallet.selectSeedHex(getDefaultHDWallet(state)) + + const entropy = secondPassword + ? crypto.decryptSecPass(sharedKey, iterations, secondPassword, candidate) + : candidate + + return BIP39.mnemonicToSeed(BIP39.entropyToMnemonic(entropy)) + } + + const deriveBIP32Key = ( + { iterations, secondPassword, sharedKey }, + { network, path } + ) => { + const seed = getSeed({ iterations, secondPassword, sharedKey }) + + return Bitcoin.HDNode.fromSeedBuffer(seed, network) + .derivePath(path) + .toBase58() + } + + const deriveSLIP10ed25519Key = ( + { iterations, secondPassword, sharedKey }, + { path } + ) => { + const seed = getSeed({ iterations, secondPassword, sharedKey }) + return ed25519.derivePath(path, seed.toString(`hex`)) + } + + return { + axios: kernel.sanitizeFunction(axios), + credentialsEntropy, + deriveBIP32Key, + deriveSLIP10ed25519Key, + dispatch: store.dispatch + } +} diff --git a/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js b/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js new file mode 100644 index 00000000000..0dffc86f10d --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/IPC/Middleware.js @@ -0,0 +1,135 @@ +import * as router from 'connected-react-router' +import * as R from 'ramda' + +import * as coreTypes from 'blockchain-wallet-v4/src/redux/actionTypes' +import * as types from '../data/actionTypes' + +const alreadyForwarded = ({ meta }) => meta && meta.forwarded + +const tag = action => ({ + ...action, + meta: { ...action.meta, forwarded: true } +}) + +const appElement = document.getElementById(`app`) +const frameElement = document.getElementById(`frame`) +const mainProcessElement = document.getElementById(`main-process`) + +const displayRootDocument = () => { + appElement.style.display = `block` + frameElement.style.borderColor = `lightgreen` + mainProcessElement.style.display = `none` +} + +const displayMainProcess = () => { + appElement.style.display = `none` + frameElement.style.borderColor = `red` + mainProcessElement.style.display = `block` +} + +const dispatchToBoth = ({ mainProcessDispatch, next }, action) => { + if (!alreadyForwarded(action)) { + mainProcessDispatch(action) + } + + next(action) +} + +const wrapperMask = { + password: undefined, + wallet: { + guid: undefined, + hd_wallets: [{ seedHex: undefined }] + } +} + +const handlers = { + // This requires the GUID. + [coreTypes.data.misc.FETCH_LOGS_FAILURE]: dispatchToBoth, + [coreTypes.data.misc.FETCH_LOGS_LOADING]: dispatchToBoth, + [coreTypes.data.misc.FETCH_LOGS_SUCCESS]: dispatchToBoth, + + // Dispatched by createRoot, which requires the mnemonic. + [coreTypes.kvStore.root.UPDATE_METADATA_ROOT]: dispatchToBoth, + + // Dispatched by createXlm, which requires the mnemonic. + [coreTypes.kvStore.xlm.CREATE_METADATA_XLM]: dispatchToBoth, + + // This requires the GUID. + [coreTypes.settings.FETCH_SETTINGS_FAILURE]: dispatchToBoth, + [coreTypes.settings.FETCH_SETTINGS_LOADING]: dispatchToBoth, + [coreTypes.settings.FETCH_SETTINGS_SUCCESS]: dispatchToBoth, + + // Report failure of wallet synchronization. + [coreTypes.walletSync.SYNC_ERROR]: dispatchToBoth, + + // Report success of wallet synchronization. + [coreTypes.walletSync.SYNC_SUCCESS]: dispatchToBoth, + + // Proceed with the login routine after receiving the payload. + [types.auth.AUTHENTICATE]: dispatchToBoth, + [types.auth.LOGIN_ROUTINE]: dispatchToBoth +} + +// Used to set the wrapper in /recover. + +handlers[coreTypes.wallet.REFRESH_WRAPPER] = ( + { mainProcessDispatch, next }, + action +) => { + const redactedPayload = action.payload.mergeDeep(wrapperMask) + mainProcessDispatch({ ...action, payload: redactedPayload }) + next(action) +} + +// Send the wrapper to the Main Process after logging in. +handlers[coreTypes.wallet.SET_WRAPPER] = + handlers[coreTypes.wallet.REFRESH_WRAPPER] + +export default ({ mainProcessDispatch, rootDocumentRoutes }) => { + const routeIsInRootDocument = pathname => + rootDocumentRoutes.some(route => pathname.startsWith(route)) + + handlers[router.LOCATION_CHANGE] = ( + { mainProcessDispatch, next, store }, + action + ) => { + const { pathname } = action.payload.location + + if (routeIsInRootDocument(pathname)) { + displayRootDocument() + + const isNewLocationFromMainProcess = + alreadyForwarded(action) && + pathname !== store.getState().router.location.pathname + + const nextAction = isNewLocationFromMainProcess + ? router.replace(pathname) + : action + + next(nextAction) + } else { + displayMainProcess() + mainProcessDispatch(action) + } + } + + return store => next => action => { + const { type } = action + + const context = { + mainProcessDispatch: R.pipe( + tag, + mainProcessDispatch + ), + next, + store + } + + if (type in handlers) { + return handlers[type](context, action) + } else { + return next(action) + } + } +} diff --git a/packages/blockchain-wallet-v4-frontend/src/IPC/index.js b/packages/blockchain-wallet-v4-frontend/src/IPC/index.js new file mode 100644 index 00000000000..6420d679761 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/IPC/index.js @@ -0,0 +1,45 @@ +import Channel from '@nodeguy/channel' + +import { serializer } from 'blockchain-wallet-v4/src/types' +import * as kernel from '../../../web-microkernel/src' + +import Exports from './Exports' +import Middleware from './Middleware' + +const rootDocumentRoutes = [ + `/help`, + `/login`, + `/recover`, + `/reminder`, + `/reset-2fa`, + `/security-center`, + `/signup` +] + +export default () => { + // The middleware dispatches actions to the Main Process store but we need to + // provide the middleware before we can import the Main Process's `dispatch` + // method. Create a channel for buffering actions to be dispatched once it's + // available. + const mainProcessActionsChannel = Channel() + + return { + Connection: async ({ input, output, outputOrigin, store }) => { + const connection = await kernel.RealmConnection({ + exports: Exports({ store }), + input, + output, + outputOrigin, + reviver: serializer.reviver + }) + + mainProcessActionsChannel.forEach(connection.imports.dispatch) + return connection + }, + + middleware: Middleware({ + mainProcessDispatch: mainProcessActionsChannel.push, + rootDocumentRoutes + }) + } +} diff --git a/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js b/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js index 0de985229bb..14e453788a5 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/auth/actionTypes.js @@ -3,6 +3,7 @@ export const DEAUTHORIZE_BROWSER = 'DEAUTHORIZE_BROWSER' export const LOGIN = 'LOGIN' export const LOGIN_FAILURE = 'LOGIN_FAILURE' export const LOGIN_LOADING = 'LOGIN_LOADING' +export const LOGIN_ROUTINE = 'LOGIN_ROUTINE' export const LOGIN_SUCCESS = 'LOGIN_SUCCESS' export const LOGOUT = 'LOGOUT' export const LOGOUT_CLEAR_REDUX_STORE = 'LOGOUT_CLEAR_REDUX_STORE' diff --git a/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js b/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js index 63600f6a11a..f1845c62e56 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/auth/actions.js @@ -7,6 +7,12 @@ export const login = (guid, password, code, sharedKey, mobileLogin) => ({ payload: { guid, password, code, sharedKey, mobileLogin } }) export const loginLoading = () => ({ type: AT.LOGIN_LOADING }) + +export const loginRoutine = (mobileLogin = false, firstLogin = false) => ({ + type: AT.LOGIN_ROUTINE, + payload: { firstLogin, mobileLogin } +}) + export const loginSuccess = () => ({ type: AT.LOGIN_SUCCESS, payload: {} }) export const loginFailure = err => ({ type: AT.LOGIN_FAILURE, diff --git a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js index f45371a6e1a..0c0effd3a99 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagaRegister.js @@ -8,6 +8,7 @@ export default ({ api, coreSagas }) => { return function* authSaga () { yield takeLatest(AT.DEAUTHORIZE_BROWSER, authSagas.deauthorizeBrowser) yield takeLatest(AT.LOGIN, authSagas.login) + yield takeLatest(AT.LOGIN_ROUTINE, authSagas.loginRoutineSaga) yield takeLatest(AT.LOGOUT, authSagas.logout) yield takeLatest( AT.LOGOUT_CLEAR_REDUX_STORE, diff --git a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js index 9b8247a6e8c..8d035bb493f 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.js @@ -92,61 +92,15 @@ export default ({ api, coreSagas }) => { if (userFlowSupported) yield put(actions.modules.profile.signIn()) } - const loginRoutineSaga = function*(mobileLogin, firstLogin) { + const loginRoutineSaga = function*({ payload: { mobileLogin, firstLogin } }) { try { - // If needed, the user should upgrade its wallet before being able to open the wallet - const isHdWallet = yield select(selectors.core.wallet.isHdWallet) - if (!isHdWallet) { - yield call(upgradeWalletSaga) - } yield put(actions.auth.authenticate()) - yield call(coreSagas.kvStore.root.fetchRoot, askSecondPasswordEnhancer) - // If there was no ethereum metadata kv store entry, we need to create one and that requires the second password. - yield call( - coreSagas.kvStore.ethereum.fetchMetadataEthereum, - askSecondPasswordEnhancer - ) - yield call( - coreSagas.kvStore.xlm.fetchMetadataXlm, - askSecondPasswordEnhancer - ) - yield call(coreSagas.kvStore.bch.fetchMetadataBch) - yield call(coreSagas.kvStore.bsv.fetchMetadataBsv) - yield call(coreSagas.kvStore.lockbox.fetchMetadataLockbox) - yield put(actions.middleware.webSocket.bch.startSocket()) - yield put(actions.middleware.webSocket.btc.startSocket()) - yield put(actions.middleware.webSocket.eth.startSocket()) - yield put(actions.middleware.webSocket.xlm.startStreams()) - yield put(actions.router.push('/home')) - yield call(coreSagas.settings.fetchSettings) - yield call(coreSagas.data.xlm.fetchLedgerDetails) - yield call(coreSagas.data.xlm.fetchData) - yield call(authNabu) - yield call(upgradeAddressLabelsSaga) - yield put(actions.auth.loginSuccess()) - yield put(actions.auth.startLogoutTimer()) // store guid in cache for future logins const guid = yield select(selectors.core.wallet.getGuid) yield put(actions.cache.guidEntered(guid)) // reset auth type and clear previous login form state yield put(actions.auth.setAuthType(0)) yield put(actions.form.destroy('login')) - // set payload language to settings language - const language = yield select(selectors.preferences.getLanguage) - yield put(actions.modules.settings.updateLanguage(language)) - yield fork(transferEthSaga) - // TODO @analytics.logEvent login flow - // yield fork(reportStats, mobileLogin) - yield put(actions.goals.saveGoal('welcome', { firstLogin })) - yield put(actions.goals.saveGoal('swapUpgrade')) - yield put(actions.goals.saveGoal('kyc')) - yield put(actions.goals.runGoals()) - yield fork(checkDataErrors) - yield put(actions.analytics.reportBalanceStats()) - yield fork(logoutRoutine, yield call(setLogoutEventListener)) - if (!firstLogin) { - yield put(actions.alerts.displaySuccess(C.LOGIN_SUCCESS)) - } } catch (e) { yield put( actions.logs.logErrorMessage(logLocation, 'loginRoutineSaga', e) @@ -222,7 +176,7 @@ export default ({ api, coreSagas }) => { password, code }) - yield call(loginRoutineSaga, mobileLogin) + yield put(actions.auth.loginRoutine(mobileLogin)) } catch (error) { const initialError = prop('initial_error', error) const authRequired = prop('authorization_required', error) @@ -245,7 +199,7 @@ export default ({ api, coreSagas }) => { session, password }) - yield call(loginRoutineSaga, mobileLogin) + yield put(actions.auth.loginRoutine(mobileLogin)) } catch (error) { if (error && error.auth_type > 0) { yield put(actions.auth.setAuthType(error.auth_type)) @@ -334,7 +288,12 @@ export default ({ api, coreSagas }) => { yield put(actions.alerts.displayInfo(C.CREATE_WALLET_INFO)) yield call(coreSagas.wallet.createWalletSaga, action.payload) yield put(actions.alerts.displaySuccess(C.REGISTER_SUCCESS)) - yield call(loginRoutineSaga, false, true) + + // Temporarily send the whole wrapper over to the Main Process. + const wrapper = yield select(selectors.core.wallet.getWrapper) + yield put(actions.core.wallet.setWrapper(wrapper)) + + yield put(actions.auth.loginRoutine(false, true)) yield put(actions.auth.registerSuccess()) } catch (e) { yield put(actions.auth.registerFailure()) @@ -349,7 +308,7 @@ export default ({ api, coreSagas }) => { yield put(actions.alerts.displayInfo(C.RESTORE_WALLET_INFO)) yield call(coreSagas.wallet.restoreWalletSaga, action.payload) yield put(actions.alerts.displaySuccess(C.RESTORE_SUCCESS)) - yield call(loginRoutineSaga, false, true) + yield put(actions.auth.loginRoutine(false, true)) yield put(actions.auth.restoreSuccess()) } catch (e) { yield put(actions.auth.restoreFailure()) diff --git a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js index b4d73337244..1d501d2dfea 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/auth/sagas.spec.js @@ -93,11 +93,11 @@ describe('authSagas', () => { }) }) - it('should call login routine', () => { + it('should put login routine', () => { const { mobileLogin } = payload saga .next() - .call(loginRoutineSaga, mobileLogin) + .put(actions.auth.loginRoutineSaga(mobileLogin)) .next() .isDone() }) @@ -185,9 +185,9 @@ describe('authSagas', () => { }) }) - it('should call login routine', () => { + it('should put login routine', () => { const { mobileLogin } = payload - saga.next().call(loginRoutineSaga, mobileLogin) + saga.next().put(actions.auth.loginRoutineSaga(mobileLogin)) }) it('should follow 2FA flow on auth error', () => { diff --git a/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js b/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js index c905bf90105..119fef2b1b3 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js @@ -18,6 +18,7 @@ const logger = (state = INITIAL_STATE, action) => { switch (type) { case AT.LOG_ERROR_MSG: { + console.error(payload) return insert(0, createLog('ERROR', payload), state) } case AT.LOG_INFO_MSG: { diff --git a/packages/blockchain-wallet-v4-frontend/src/index.html b/packages/blockchain-wallet-v4-frontend/src/index.html index 7858507df44..10db155d202 100644 --- a/packages/blockchain-wallet-v4-frontend/src/index.html +++ b/packages/blockchain-wallet-v4-frontend/src/index.html @@ -1,27 +1,69 @@ - - - - - - - - - - - - - - - - - Blockchain Wallet - Exchange Cryptocurrency - - - -
- + + + + +
+
+ +
+ diff --git a/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.js b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.js new file mode 100644 index 00000000000..d0a9eacf4ba --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.js @@ -0,0 +1,53 @@ +import React from 'react' +import { FormattedMessage } from 'react-intl' +import { Link as RouterLink } from 'react-router-dom' +import styled from 'styled-components' + +import { Image, Link } from 'blockchain-info-components' +import { Navbar, NavbarBrand } from 'components/Navbar' + +const White = styled.div` + color: white; + + a:link { + color: white; + } + + a:visited { + color: white; + } +` + +const Dashboard = styled.div` + padding-right: 25px; +` + +const Header = () => { + return ( + + + + + + + + + + + + + + + + + + ) +} + +export default Header diff --git a/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.spec.js b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.spec.js new file mode 100644 index 00000000000..7f8c9a4e134 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/Header/index.spec.js @@ -0,0 +1,14 @@ +import React from 'react' +import { shallow } from 'enzyme' +import toJson from 'enzyme-to-json' +import Header from './index' + +jest.mock('blockchain-info-components', () => ({ Image: '', Link: '' })) + +describe('Public Header Component', () => { + it('should match snapshot', () => { + const component = shallow(
) + const tree = toJson(component) + expect(tree).toMatchSnapshot() + }) +}) diff --git a/packages/blockchain-wallet-v4-frontend/src/layouts/Security/index.js b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/index.js new file mode 100644 index 00000000000..4189eee7250 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/layouts/Security/index.js @@ -0,0 +1,114 @@ +import Modals from 'modals' +import React from 'react' +import { connect } from 'react-redux' +import { Route } from 'react-router-dom' +import styled from 'styled-components' + +import Header from './Header' +import AnalyticsTracker from 'providers/AnalyticsTracker' +import ErrorBoundary from 'providers/ErrorBoundaryProvider' +import { selectors } from 'data' +import { isOnDotInfo } from 'services/MigrationService' + +const defaultDomains = { + root: 'https://blockchain.info', + comWalletApp: 'https://login.blockchain.com', + comRoot: 'https://blockchain.com' +} + +const Wrapper = styled.div` + height: auto; + min-height: 100%; + width: 100%; + overflow: auto; + + @media (min-width: 768px) { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + height: 100%; + } +` +const HeaderContainer = styled.div` + position: relative; + width: 100%; + + @media (min-width: 768px) { + top: 0; + left: 0; + } +` +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + overflow-y: auto; + margin: 0 25px; + + @media (min-width: 768px) { + height: 100%; + } + + @media (min-height: 1000px) { + height: 100%; + margin-top: 200px; + justify-content: flex-start; + } + + @media (min-height: 1400px) { + height: 100%; + margin-top: 500px; + justify-content: flex-start; + } +` + +class SecurityLayoutContainer extends React.PureComponent { + componentDidMount () { + const { domainsR, migrationRedirectsR, pathname } = this.props + const domains = domainsR.getOrElse(defaultDomains) + const enableRedirects = migrationRedirectsR.getOrElse(false) + + if (enableRedirects && isOnDotInfo(domains)) { + if (pathname === '/wallet') { + window.location = `${domains.comRoot}/wallet` + } else { + window.location = `${domains.comWalletApp}/${pathname}` + } + } + } + + render () { + const { component: Component, ...rest } = this.props + return ( + + ( + + + + + +
+ + + + + + + )} + /> + + ) + } +} + +const mapStateToProps = state => ({ + pathname: selectors.router.getPathname(state), + domainsR: selectors.core.walletOptions.getDomains(state), + migrationRedirectsR: selectors.core.walletOptions.getMigrationRedirects(state) +}) + +export default connect(mapStateToProps)(SecurityLayoutContainer) diff --git a/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/PairingCode/index.js b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/PairingCode/index.js new file mode 100644 index 00000000000..bb637a8a36b --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/PairingCode/index.js @@ -0,0 +1,89 @@ +import React from 'react' +import styled from 'styled-components' +import { FormattedMessage } from 'react-intl' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' + +import { actions } from 'data' +import { Badge, Button, Text } from 'blockchain-info-components' +import { + SettingComponent, + SettingContainer, + SettingDescription, + SettingHeader, + SettingSummary +} from 'components/Setting' + +const BadgesContainer = styled.div` + display: block; + padding-top: 10px; + & > * { + display: inline; + margin-right: 5px; + } +` + +class PairingCode extends React.PureComponent { + constructor (props) { + super(props) + this.onShowCode = this.onShowCode.bind(this) + } + + onShowCode () { + this.props.actions.showModal('PairingCode') + } + + render () { + return ( + + + + + + + + + + + + + + + + + + + + + + ) + } +} + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(actions.modals, dispatch) +}) + +export default connect( + null, + mapDispatchToProps +)(PairingCode) diff --git a/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/WalletId/index.js b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/WalletId/index.js new file mode 100644 index 00000000000..7f054bab0b5 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/WalletId/index.js @@ -0,0 +1,49 @@ +import React from 'react' +import { FormattedMessage } from 'react-intl' +import { connect } from 'react-redux' + +import { selectors } from 'data' +import { Text } from 'blockchain-info-components' +import { + SettingComponent, + SettingContainer, + SettingDescription, + SettingHeader, + SettingSummary +} from 'components/Setting' + +const WalletId = props => { + return ( + + + + + + + + + + + + + + {props.guid} + + + ) +} + +const mapStateToProps = state => ({ + guid: selectors.core.wallet.getGuid(state) +}) + +export default connect(mapStateToProps)(WalletId) diff --git a/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/index.js b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/index.js index d081f2b4e62..e75108992c4 100644 --- a/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/index.js +++ b/packages/blockchain-wallet-v4-frontend/src/scenes/SecurityCenter/AdvancedSecurity/index.js @@ -5,8 +5,10 @@ import ActivityLogging from './ActivityLogging' import APIAccess from './APIAccess' import IPWhitelist from './IPWhitelist' import LoginIpRestriction from './LoginIpRestriction' +import PairingCode from './PairingCode' import PasswordStretching from './PasswordStretching' import WalletAccessTor from './WalletAccessTor' +import WalletId from './WalletId' import TwoStepVerificationRemember from './TwoStepVerificationRemember' import WalletPassword from './WalletPassword' import SecondPasswordWallet from './SecondPasswordWallet' @@ -20,6 +22,8 @@ export default class AdvancedSecurity extends React.PureComponent { render () { return ( + + diff --git a/packages/blockchain-wallet-v4-frontend/src/scenes/app.js b/packages/blockchain-wallet-v4-frontend/src/scenes/app.js index 4406208a33f..dade3e3045e 100644 --- a/packages/blockchain-wallet-v4-frontend/src/scenes/app.js +++ b/packages/blockchain-wallet-v4-frontend/src/scenes/app.js @@ -11,6 +11,7 @@ import { MediaContextProvider } from 'providers/MatchMediaProvider' import ConnectedIntlProvider from 'providers/ConnectedIntlProvider' import ThemeProvider from 'providers/ThemeProvider' import PublicLayout from 'layouts/Public' +import SecurityLayout from 'layouts/Security' import WalletLayout from 'layouts/Wallet' import AuthorizeLogin from './AuthorizeLogin' import BuySell from './BuySell' @@ -113,7 +114,7 @@ class App extends React.PureComponent { component={ExchangeProfile} /> - diff --git a/packages/blockchain-wallet-v4-frontend/src/store/index.js b/packages/blockchain-wallet-v4-frontend/src/store/index.js index 2efff0316cf..98c8a924673 100644 --- a/packages/blockchain-wallet-v4-frontend/src/store/index.js +++ b/packages/blockchain-wallet-v4-frontend/src/store/index.js @@ -9,15 +9,18 @@ import { head } from 'ramda' import Bitcoin from 'bitcoinjs-lib' import BitcoinCash from 'bitcoinforksjs-lib' -import { coreMiddleware } from 'blockchain-wallet-v4/src' +import { coreMiddleware, coreSagasFactory } from 'blockchain-wallet-v4/src' import { createWalletApi, Socket, ApiSocket, HorizonStreamingService } from 'blockchain-wallet-v4/src/network' + import { serializer } from 'blockchain-wallet-v4/src/types' import { actions, rootSaga, rootReducer, selectors } from 'data' +import IPC from '../IPC' + import { autoDisconnection, streamingXlm, @@ -27,8 +30,11 @@ import { webSocketRates } from '../middleware' +import * as kernel from '../../../web-microkernel/src' + const devToolsConfig = { maxAge: 1000, + name: `Root Document`, serialize: serializer, actionsBlacklist: [ // '@@redux-form/INITIALIZE', @@ -43,7 +49,7 @@ const devToolsConfig = { ] } -const configureStore = () => { +const configureStore = async () => { const history = createHashHistory() const sagaMiddleware = createSagaMiddleware() const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ @@ -53,108 +59,127 @@ const configureStore = () => { const kvStorePath = 'wallet.kvstore' const isAuthenticated = selectors.auth.isAuthenticated - return fetch('/Resources/wallet-options-v4.json') - .then(res => res.json()) - .then(options => { - const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' - // TODO: deprecate when wallet-options-v4 is updated on prod - const socketUrl = head(options.domains.webSocket.split('/inv')) - const horizonUrl = options.domains.horizon - const btcSocket = new Socket({ - options, - url: `${socketUrl}/inv` - }) - const bchSocket = new Socket({ - options, - url: `${socketUrl}/bch/inv` - }) - const ethSocket = new Socket({ - options, - url: `${socketUrl}/eth/inv` - }) - const ratesSocket = new ApiSocket({ - options, - url: `${socketUrl}/nabu-gateway/markets/quotes`, - maxReconnects: 3 - }) - const xlmStreamingService = new HorizonStreamingService({ - url: horizonUrl - }) - const getAuthCredentials = () => - selectors.modules.profile.getAuthCredentials(store.getState()) - const reauthenticate = () => - store.dispatch(actions.modules.profile.signIn()) - const networks = { - btc: Bitcoin.networks[options.platforms.web.btc.config.network], - bch: BitcoinCash.networks[options.platforms.web.btc.config.network], - bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], - eth: options.platforms.web.eth.config.network, - xlm: options.platforms.web.xlm.config.network - } - const api = createWalletApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) - const persistWhitelist = ['session', 'preferences', 'cache'] - - // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) - const store = createStore( - connectRouter(history)( - persistCombineReducers( - { - getStoredState: getStoredStateMigrateV4({ - whitelist: persistWhitelist - }), - key: 'root', - storage, - whitelist: persistWhitelist - }, - rootReducer - ) - ), - composeEnhancers( - applyMiddleware( - sagaMiddleware, - routerMiddleware(history), - coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), - webSocketBtc(btcSocket), - webSocketBch(bchSocket), - webSocketEth(ethSocket), - streamingXlm(xlmStreamingService, api), - webSocketRates(ratesSocket), - coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), - autoDisconnection() - ) - ) + const options = await (await fetch( + '/Resources/wallet-options-v4.json' + )).json() + + const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' + // TODO: deprecate when wallet-options-v4 is updated on prod + const socketUrl = head(options.domains.webSocket.split('/inv')) + const horizonUrl = options.domains.horizon + const btcSocket = new Socket({ + options, + url: `${socketUrl}/inv` + }) + const bchSocket = new Socket({ + options, + url: `${socketUrl}/bch/inv` + }) + const ethSocket = new Socket({ + options, + url: `${socketUrl}/eth/inv` + }) + const ratesSocket = new ApiSocket({ + options, + url: `${socketUrl}/nabu-gateway/markets/quotes`, + maxReconnects: 3 + }) + const xlmStreamingService = new HorizonStreamingService({ + url: horizonUrl + }) + + const getAuthCredentials = () => + selectors.modules.profile.getAuthCredentials(store.getState()) + const reauthenticate = () => store.dispatch(actions.modules.profile.signIn()) + const networks = { + btc: Bitcoin.networks[options.platforms.web.btc.config.network], + bch: BitcoinCash.networks[options.platforms.web.btc.config.network], + bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], + eth: options.platforms.web.eth.config.network, + xlm: options.platforms.web.xlm.config.network + } + const api = createWalletApi({ + options, + apiKey, + getAuthCredentials, + reauthenticate, + networks + }) + const persistWhitelist = ['session', 'preferences', 'cache'] + + const { Connection: MainProcessConnection, middleware: IPCmiddleware } = IPC() + + // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) + const store = createStore( + connectRouter(history)( + persistCombineReducers( + { + getStoredState: getStoredStateMigrateV4({ + whitelist: persistWhitelist + }), + key: 'root', + storage, + whitelist: persistWhitelist + }, + rootReducer + ) + ), + composeEnhancers( + applyMiddleware( + IPCmiddleware, + sagaMiddleware, + routerMiddleware(history), + coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), + webSocketBtc(btcSocket), + webSocketBch(bchSocket), + webSocketEth(ethSocket), + streamingXlm(xlmStreamingService, api), + webSocketRates(ratesSocket), + coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), + autoDisconnection() ) - const persistor = persistStore(store, null) - - sagaMiddleware.run(rootSaga, { - api, - bchSocket, - btcSocket, - ethSocket, - ratesSocket, - networks, - options - }) - - // expose globals here - window.createTestXlmAccounts = () => { - store.dispatch(actions.core.data.xlm.createTestAccounts()) - } - - store.dispatch(actions.goals.defineGoals()) - - return { - store, - history, - persistor - } - }) + ) + ) + const persistor = persistStore(store, null) + + sagaMiddleware.run(rootSaga, { + api, + bchSocket, + btcSocket, + ethSocket, + ratesSocket, + networks, + options + }) + + const coreSagas = coreSagasFactory({ api, networks }) + + const mainProcessWindow = document.getElementById(`main-process`) + .contentWindow + + const mainProcessConnection = await MainProcessConnection({ + coreSagas, + input: kernel.multiplexRealm({ tag: `realms` }, window), + output: kernel.multiplexRealm({ tag: `realms` }, mainProcessWindow), + outputOrigin: options.domains.mainProcess, + sagaMiddleware, + store + }) + + mainProcessConnection.addEventListener(`error`, console.error) + + // expose globals here + window.createTestXlmAccounts = () => { + store.dispatch(actions.core.data.xlm.createTestAccounts()) + } + + store.dispatch(actions.goals.defineGoals()) + + return { + store, + history, + persistor + } } export default configureStore diff --git a/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js b/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js index ca6f7010fc1..1bf71ad2cd4 100644 --- a/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js +++ b/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js @@ -199,6 +199,7 @@ module.exports = { app.get('/Resources/wallet-options-v4.json', function(req, res) { // combine wallet options base with custom environment config mockWalletOptions.domains = { + mainProcess: `http://localhost:8082`, root: envConfig.ROOT_URL, api: envConfig.API_DOMAIN, webSocket: envConfig.WEB_SOCKET_URL, @@ -257,7 +258,7 @@ module.exports = { "style-src 'self' 'unsafe-inline'", `frame-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} ${ envConfig.ROOT_URL - } https://magic.veriff.me https://localhost:8080 http://localhost:8080`, + } https://magic.veriff.me https://localhost:8080 http://localhost:8080 https://localhost:8082 http://localhost:8082`, `child-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} blob:`, [ 'connect-src', diff --git a/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js b/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js index a9e32db86a0..386ce82683b 100755 --- a/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js +++ b/packages/blockchain-wallet-v4/src/redux/kvStore/root/sagas.js @@ -32,8 +32,7 @@ export default ({ api, networks }) => { KVStoreEntry.getMasterHDNode(networks.btc) ) const metadataNode = getMetadataNode(seedHex) - const metadata = metadataNode.toBase58() - yield put(A.updateMetadataRoot({ metadata })) + return metadataNode.toBase58() } catch (e) { throw new Error('create root Metadata :: Error decrypting mnemonic') } @@ -64,6 +63,7 @@ export default ({ api, networks }) => { } return { + createRoot, fetchRoot } } diff --git a/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js b/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js index 58369d9a087..e958b99e1d3 100755 --- a/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js +++ b/packages/blockchain-wallet-v4/src/redux/kvStore/xlm/sagas.js @@ -26,8 +26,7 @@ export default ({ api, networks } = {}) => { ], tx_notes: {} } - const newkv = set(KVStoreEntry.value, xlm, kv) - yield put(A.createMetadataXlm(newkv)) + return set(KVStoreEntry.value, xlm, kv) } catch (e) { throw new Error( '[NOT IMPLEMENTED] MISSING_SECOND_PASSWORD in core.createXlm saga' diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap b/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap new file mode 100644 index 00000000000..3169e4b7e68 --- /dev/null +++ b/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reducers wallet should handle MERGE_WRAPPER 1`] = ` +Immutable.Map { + "sync_pubkeys": false, + "payload_checksum": "payload_checksum", + "storage_token": "storage_token", + "version": 3, + "language": "en", + "wallet": Immutable.Map { + "addresses": Immutable.Map { + "19XmKRY66VnUn5irHAafyoTfiwFuGLUxKF": Immutable.Map { + "addr": "19XmKRY66VnUn5irHAafyoTfiwFuGLUxKF", + "priv": "38W3vsxt246Lhawvz81y2HQVnsJrBUX87jSTUUnixPsE", + "tag": 0, + "label": "", + "created_time": 1492721419269, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + "14mQxLtEagsS8gYsdWJbzthFFuPDqDgtxQ": Immutable.Map { + "addr": "14mQxLtEagsS8gYsdWJbzthFFuPDqDgtxQ", + "priv": "BpD2ZuJjZ8PJPpDX6ZmsKFsXHkL7XV3dt385zghMfF6C", + "tag": 0, + "label": "labeled_imported", + "created_time": 1492721432222, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + "1JD73aGSdeqKUjJ4ntP4eCyUiuZ3ogJE1u": Immutable.Map { + "addr": "1JD73aGSdeqKUjJ4ntP4eCyUiuZ3ogJE1u", + "priv": null, + "tag": 0, + "label": "", + "created_time": 1492721461228, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + }, + "tx_notes": Immutable.Map {}, + "guid": "current", + "metadataHDNode": "xprv9tygGQP8be7uzNm5Czuy41juTK9pUKnWyZtDxgbmSEcCYa9VdvvtSknEyiKitqqm2TMv14NjXPQ68XLwSdH6Scc5GwXoZ31yRZZysxhVGU7", + "tx_names": Immutable.List [], + "double_encryption": false, + "address_book": Immutable.Map {}, + "hd_wallets": Immutable.List [ + Immutable.Map { + "seed_hex": "current", + "seedHex": "6a4d9524d413fdf69ca1b5664d1d6db0", + "passphrase": "", + "mnemonic_verified": false, + "default_account_idx": 0, + "accounts": Immutable.List [ + Immutable.Map { + "label": "My Bitcoin Wallet", + "archived": false, + "xpriv": "xprv9yL1ousLjQQzGNBAYykaT8J3U626NV6zbLYkRv8rvUDpY4f1RnrvAXQneGXC9UNuNvGXX4j6oHBK5KiV2hKevRxY5ntis212oxjEL11ysuG", + "xpub": "xpub6CKNDRQEZmyHUrFdf1HapGEn27ramwpqxZUMEJYUUokoQrz9yLBAiKjGVWDuiCT39udj1r3whqQN89Tar5KrojH8oqSy7ytzJKW8gwmhwD3", + "address_labels": Immutable.Map { + "0": Immutable.Map { + "index": 0, + "label": "labeled_address", + }, + }, + "cache": Immutable.Map { + "receiveAccount": "xpub6F41z8MqNcJMvKQgAd5QE2QYo32cocYigWp1D8726ykMmaMqvtqLkvuL1NqGuUJvU3aWyJaV2J4V6sD7Pv59J3tYGZdYRSx8gU7EG8ZuPSY", + "changeAccount": "xpub6F41z8MqNcJMwmeUExdCv7UXvYBEgQB29SWq9jyxuZ7WefmSTWcwXB6NRAJkGCkB3L1Eu4ttzWnPVKZ6REissrQ4i6p8gTi9j5YwDLxmZ8p", + }, + "index": 0, + }, + ], + }, + ], + "sharedKey": "8a260b2b-5257-4357-ac56-7a7efca323ea", + "options": Immutable.Map { + "pbkdf2_iterations": 5000, + "fee_per_kb": 10000, + "html5_notifications": false, + "logout_time": 600000, + }, + }, + "war_checksum": "war_checksum", + "password": "current", + "pbkdf2_iterations": 5000, +} +`; diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js b/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js index 1e71c3b27bd..6211d6c7718 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js @@ -1,4 +1,5 @@ // setters +export const MERGE_WRAPPER = '@CORE.MERGE_WRAPPER' export const SET_WRAPPER = '@CORE.SET_WRAPPER' export const REFRESH_WRAPPER = '@CORE.REFRESH_WRAPPER' export const SET_MAIN_PASSWORD = '@CORE.SET_MAIN_PASSWORD' diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/actions.js b/packages/blockchain-wallet-v4/src/redux/wallet/actions.js index 624d77e3563..4e6c7b28f3c 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/actions.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/actions.js @@ -1,6 +1,9 @@ import * as T from './actionTypes' // setters + +export const mergeWrapper = payload => ({ type: T.MERGE_WRAPPER, payload }) + export const setWrapper = payload => ({ type: T.SET_WRAPPER, payload: payload diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js b/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js index e432a1e65e3..fe90b0af3b4 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js @@ -8,6 +8,29 @@ export const WRAPPER_INITIAL_STATE = Wrapper.fromJS( Wrapper.createNewReadOnly('', '') ) +// an object containing reducer methods for each type of action +const reducers = {} + +// MERGE_WRAPPER: Merge wrapper from the Main Process. + +const wrapperMask = { + password: undefined, + wallet: { + guid: undefined, + hd_wallets: [{ seedHex: undefined }] + } +} + +const wrapperMerger = (oldValue, newValue) => + newValue === undefined ? oldValue : newValue + +reducers[T.MERGE_WRAPPER] = (state, { payload }) => { + const redactedPayload = payload.mergeDeep(wrapperMask) + return state.mergeDeepWith(wrapperMerger, redactedPayload) +} + +// + export const wrapperReducer = (state = WRAPPER_INITIAL_STATE, action) => { const { type } = action switch (type) { @@ -110,8 +133,13 @@ export const wrapperReducer = (state = WRAPPER_INITIAL_STATE, action) => { ) return set(mvLens, true, state) } - default: - return state + default: { + if (type in reducers) { + return reducers[type](state, action) + } else { + return state + } + } } } diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js b/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js index 18c048cfe29..2b5f22e6577 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js @@ -1,3 +1,4 @@ +import { fromJS } from 'immutable' import { compose } from 'ramda' import { Wrapper, Wallet, AddressMap } from '../../types' import walletReducer from './reducers.js' @@ -20,6 +21,22 @@ describe('reducers', () => { describe('wallet', () => { const wrapped = Wrapper.fromJS(wrap(walletFixture)) + it('should handle MERGE_WRAPPER', () => { + const state = fromJS({ + password: `current`, + version: 4, + wallet: { + guid: `current`, + hd_wallets: [{ seedHex: `current` }], + sharedKey: `current` + } + }) + + const action = Actions.mergeWrapper(wrapped) + const next = walletReducer(state, action) + expect(next).toMatchSnapshot() + }) + it('should handle SET_WRAPPER', () => { let action = Actions.setWrapper(wrapped) let next = walletReducer(void 0, action) diff --git a/packages/blockchain-wallet-v4/src/remote/index.js b/packages/blockchain-wallet-v4/src/remote/index.js index 845f7bcb156..5f45170cc5e 100755 --- a/packages/blockchain-wallet-v4/src/remote/index.js +++ b/packages/blockchain-wallet-v4/src/remote/index.js @@ -1,3 +1,5 @@ +// https://medium.com/@jaumepernas/your-data-is-loading-1425c6b76bf0 + import { taggedSum } from 'daggy' const Remote = taggedSum('Remote', { diff --git a/packages/blockchain-wallet-v4/src/types/Serializer.js b/packages/blockchain-wallet-v4/src/types/Serializer.js index 2eb76750fc7..977694c5daf 100755 --- a/packages/blockchain-wallet-v4/src/types/Serializer.js +++ b/packages/blockchain-wallet-v4/src/types/Serializer.js @@ -1,3 +1,5 @@ +import * as R from 'ramda' + import * as Wrapper from './Wrapper' import * as HDWallet from './HDWallet' import * as HDAccount from './HDAccount' @@ -17,6 +19,11 @@ import * as Options from './Options' import * as KVStoreEntry from './KVStoreEntry' import Remote from '../remote' +const replaceError = error => ({ + ...R.pick([`message`, `stack`], error), + ...error +}) + const serializer = { replacer: function (key, value) { // Remove all functions from the state @@ -27,10 +34,13 @@ const serializer = { if (key === 'syncErrors') { return '' } - return value + + return value instanceof Error ? replaceError(value) : value }, reviver: function (key, value) { - if ( + if (value && value.type === 'Buffer') { + return Buffer.from(value.data) + } else if ( typeof value === 'object' && value !== null && '__serializedType__' in value diff --git a/packages/web-microkernel/package.json b/packages/web-microkernel/package.json new file mode 100644 index 00000000000..53fd5419f44 --- /dev/null +++ b/packages/web-microkernel/package.json @@ -0,0 +1,9 @@ +{ + "name": "web-microkernel", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "lodash-es": "4.17.11" + } +} diff --git a/packages/web-microkernel/src/RealmConnection.js b/packages/web-microkernel/src/RealmConnection.js new file mode 100644 index 00000000000..71f9cecd313 --- /dev/null +++ b/packages/web-microkernel/src/RealmConnection.js @@ -0,0 +1,441 @@ +import * as _ from './lodash-es/lodash.js' + +const fromEntries = entries => + Object.assign({}, ...entries.map(([key, value]) => ({ [key]: value }))) + +// Polyfill the behavior of addEventListener(type, listener, { once: true })) +// because for an unknown reason the above isn't working in Chrome 72. +const addEventListenerOnce = (target, type, listener) => { + const onceListener = (...args) => { + const result = listener(...args) + target.removeEventListener(type, onceListener) + return result + } + + target.addEventListener(type, onceListener) +} + +const firstEvent = (target, type) => + new Promise(resolve => { + addEventListenerOnce(target, type, resolve) + }) + +const inspectionCutoff = 40 + +export const inspect = value => { + const stringified = JSON.stringify(value) + + return stringified.length > inspectionCutoff + ? `${stringified.slice(0, inspectionCutoff)}...` + : stringified +} + +// Create an unforgeable key. +const Key = () => { + const array = new Uint32Array(1) + window.crypto.getRandomValues(array) + return array[0].toString(36) +} + +const decoders = {} +const types = [] + +// The types are defined in the same order they are tested. + +types.push( + { + name: `boolean`, + test: _.isBoolean, + encode: (context, boolean) => boolean, + decode: (context, code) => code + }, + + { + name: `null`, + test: _.isNull, + encode: () => null, + decode: () => null + }, + + { + name: `number`, + test: _.isNumber, + encode: (context, number) => number, + decode: (context, code) => code + }, + + { + name: `string`, + test: _.isString, + encode: (context, string) => string, + decode: (context, code) => code + }, + + { + name: `undefined`, + test: _.isUndefined, + encode: () => undefined, + decode: () => undefined + }, + + { + name: `array`, + test: Array.isArray, + encode: (context, array) => + array.map((value, index) => + context.memoizedEncode(context, value, index) + ), + + decode: (context, codes) => + codes.map((code, index) => context.memoizedDecode(context, code, index)) + } +) + +// error + +const isError = value => value instanceof Error + +const encodeError = (context, error) => ({ + // The `message` property has to be handled separately. + message: context.memoizedEncode(context, error.message), + + // Encode all properties (not just standard `message` and `name`) for + // the benefit of Axios. + entries: encodeObject(context, error) +}) + +const decodeError = (context, { entries, message }) => + Object.assign( + Error(context.memoizedDecode(context, message)), + decodeObject(context, entries) + ) + +types.push({ + name: `error`, + test: isError, + encode: encodeError, + decode: decodeError +}) + +// function + +const encodeFunction = ({ functionKeys, keyedReferences }, func) => { + if (!keyedReferences) { + throw new TypeError(`Cannot encode functions outside of exports.`) + } + + if (!functionKeys.has(func)) { + const key = Key() + functionKeys.set(func, key) + keyedReferences[key] = func + } + + return { key: functionKeys.get(func), length: func.length } +} + +const decodeFunction = (context, { key: functionKey, length }) => { + const { keyedReferences, reportExceptionsIn } = context + + if (!(functionKey in keyedReferences)) { + const proxyFunction = (...args) => + new Promise( + reportExceptionsIn((resolve, reject) => { + const returnValueKey = Key() + keyedReferences[returnValueKey] = { resolve, reject } + + // Function application isn't a type so encode it manually. + context.postMessage([ + `functionApply`, + { + args: encodeWithoutPersistentReferences(context, args), + functionKey, + returnValueKey + } + ]) + }) + ) + + Object.defineProperty(proxyFunction, `length`, { value: length }) + keyedReferences[functionKey] = proxyFunction + } + + return keyedReferences[functionKey] +} + +decoders.functionApply = (context, { args, functionKey, returnValueKey }) => { + const { keyedReferences, postMessage, reportExceptionsIn } = context + const func = keyedReferences[functionKey] + const decodedArgs = decode(context, args) + + const functionReturn = key => + reportExceptionsIn(value => { + // Function return isn't a type so encode it manually. + postMessage([ + `functionReturn`, + { + returnValueKey, + [key]: encodeWithoutPersistentReferences(context, value) + } + ]) + }) + + Promise.resolve(func(...decodedArgs)).then( + functionReturn(`value`), + functionReturn(`reason`) + ) +} + +decoders.functionReturn = (context, { returnValueKey, reason, value }) => { + const { keyedReferences } = context + const { reject, resolve } = keyedReferences[returnValueKey] + delete keyedReferences[returnValueKey] + + if (reason) { + reject(decode(context, reason)) + } else { + resolve(decode(context, value)) + } +} + +types.push({ + name: `function`, + test: _.isFunction, + encode: encodeFunction, + decode: decodeFunction +}) + +// map + +const encodeMap = (context, map) => + [...map.entries()].map(([key, value]) => [ + context.memoizedEncode(context, key), + context.memoizedEncode(context, value, key) + ]) + +const decodeMap = (context, pairs) => + new Map( + pairs.map(([encodedKey, encodedValue]) => { + const key = context.memoizedDecode(context, encodedKey) + const value = context.memoizedDecode(context, encodedValue, key) + return [key, value] + }) + ) + +types.push({ + name: `map`, + test: _.isMap, + encode: encodeMap, + decode: decodeMap +}) + +// set + +const encodeSet = (context, set) => + [...set].map(value => context.memoizedEncode(context, value)) + +const decodeSet = (context, codes) => + new Set(codes.map(code => context.memoizedDecode(context, code))) + +types.push({ + name: `set`, + test: _.isSet, + encode: encodeSet, + decode: decodeSet +}) + +// object + +const encodeObject = (context, object) => + Object.entries(object).map(([key, value]) => [ + key, + context.memoizedEncode(context, value, key) + ]) + +const decodeObject = (context, entries) => + fromEntries( + entries.map(([key, encodedValue]) => [ + key, + context.memoizedDecode(context, encodedValue, key) + ]) + ) + +types.push({ + name: `object`, + test: _.isPlainObject, + encode: encodeObject, + decode: decodeObject +}) + +// end of type definitions + +types.forEach(({ name, decode }) => { + decoders[name] = decode +}) + +const encodeWithType = (context, value, key = ``) => { + let json + + try { + json = value.toJSON(key) + } catch (exception) { + const type = types.find(({ test }) => test(value)) + + if (type) { + return [type.name, type.encode(context, value)] + } else { + throw new TypeError(`Don't know how to encode "${inspect(value)}".`) + } + } + + return encodeWithType(context, json, key) +} + +const memoizeResolver = (context, value) => value + +const encode = (context, value) => { + const memoizedEncode = _.memoize(encodeWithType, memoizeResolver) + + try { + return memoizedEncode({ ...context, memoizedEncode }, value) + } catch (exception) { + throw Object.assign( + new Error(`Exception while encoding ${inspect(value)}`), + { exception, value } + ) + } +} + +const encodeWithoutPersistentReferences = (context, value) => + encode( + { + ...context, + functionKeys: null, + keyedReferences: null + }, + value + ) + +const decodeFromType = (context, code, key = ``) => { + const [type, encoding] = code + const decode = decoders[type] + + if (decode === undefined) { + throw new TypeError(`Don't know how to decode type ${inspect(type)}.`) + } + + const value = decode(context, encoding) + const revived = context.reviver(key, value) + + // Freeze the newly created value because it's read-only: Changes wouldn't + // otherwise propogate back to the original value in the other realm. + try { + return Object.freeze(revived) + } catch (exception) { + // Some types cannot be frozen (e.g., array buffer views with elements). + return revived + } +} + +const decode = (context, code) => { + const memoizedDecode = _.memoize(decodeFromType, memoizeResolver) + return memoizedDecode({ ...context, memoizedDecode }, code) +} + +const defaultReviver = (key, value) => value + +const ReportExceptionsIn = eventTarget => callback => (...args) => { + try { + return callback(...args) + } catch (exception) { + eventTarget.dispatchEvent( + new ErrorEvent(`error`, { + error: exception, + message: exception.message + }) + ) + } +} + +export const canSerialize = value => + Boolean(types.find(({ test }) => test(value))) + +const isSanitary = value => !_.isFunction(value) && canSerialize(value) + +const sanitizeError = error => + Object.assign(new Error(error.message), sanitizeObject(error)) + +const sanitizeObject = object => + fromEntries( + Object.entries(object) + .filter(([, value]) => isSanitary(value)) + .map(([key, value]) => [key, sanitize(value)]) + ) + +export const sanitize = value => + isError(value) + ? sanitizeError(value) + : _.isPlainObject(value) + ? sanitizeObject(value) + : isSanitary(value) + ? value + : null + +export const sanitizeFunction = callback => async (...args) => { + try { + return sanitize(await callback(...args.map(sanitize))) + } catch (exception) { + throw sanitize(exception) + } +} + +export default async ({ + exports, + input, + output, + outputOrigin, + reviver = defaultReviver +}) => { + const postMessage = message => { + // console.log(`-> ${outputOrigin} ${JSON.stringify(message)}`) + output.postMessage(message, outputOrigin) + } + + const eventTarget = new EventTarget() + const reportExceptionsIn = ReportExceptionsIn(eventTarget) + + const context = { + functionKeys: new Map(), + keyedReferences: {}, + postMessage, + reportExceptionsIn, + reviver + } + + const postExports = () => postMessage(encode(context, exports)) + postExports() + const { data } = await firstEvent(input, `message`) + const imports = decode(context, data) + + // We've already posted our exports but post them a second time in case the + // other realm wasn't listening yet. The fact that we've received a handshake + // means they're listening now. Posting the exports is idempotent. + postExports() + + // Now that we've completed the handshake, listen for all future message + // events. + + const messageListener = reportExceptionsIn(({ data }) => { + // console.log(`<- ${JSON.stringify(data)}`) + decode(context, data) + }) + + input.addEventListener(`message`, messageListener) + + return Object.assign(eventTarget, { + close: () => { + input.removeEventListener(`message`, messageListener) + }, + + imports + }) +} diff --git a/packages/web-microkernel/src/RealmConnection.test.js b/packages/web-microkernel/src/RealmConnection.test.js new file mode 100644 index 00000000000..70f1ffbf9de --- /dev/null +++ b/packages/web-microkernel/src/RealmConnection.test.js @@ -0,0 +1,406 @@ +import Connection, * as internal from './RealmConnection.js' + +it(`stringifies a value for debugging`, () => { + expect( + internal.inspect([{ a: `This is a very, very, very long string.` }]) + ).toEqual(`[{"a":"This is a very, very, very long s...`) +}) + +// a cheap exercise of the destructured clone operation that occurs between +// realm boundaries +const DestructuredClone = () => { + const resolves = [] + + window.addEventListener(`message`, ({ data }) => { + const resolve = resolves.shift() + resolve(data) + }) + + return value => + new Promise(resolve => { + resolves.push(resolve) + window.postMessage(value) + }) +} + +const destructuredClone = DestructuredClone() + +const MockRealm = origin => { + let target + + return Object.assign(new EventTarget(), { + // Connect to another realm. + connect: realm => { + target = realm + }, + + postMessage: async (message, targetOrigin) => { + expect(targetOrigin).toEqual(origin) + + if (target) { + const event = new MessageEvent(`message`, { + data: await destructuredClone(message) + }) + + target.dispatchEvent(event) + } + } + }) +} + +const createMockRealms = () => { + const a = MockRealm(`a`) + const b = MockRealm(`b`) + a.connect(b) + b.connect(a) + return { a, b } +} + +xdescribe(`type checkers`, () => { + it(`array`, () => { + expect(realms.is.array(null)).toEqual(false) + expect(realms.is.array([1, 2, 3])).toEqual(true) + }) + + it(`boolean`, () => { + expect(realms.is.boolean(null)).toEqual(false) + expect(realms.is.boolean(true)).toEqual(true) + }) + + it(`function`, () => { + expect(realms.is.function(null)).toEqual(false) + expect(realms.is.function(() => {})).toEqual(true) + }) + + it(`map`, () => { + expect(realms.is.map(null)).toEqual(false) + expect(realms.is.map(new Map([[`a`, 1], [`b`, 2], [`c`, 3]]))).toEqual(true) + }) + + it(`null`, () => { + expect(realms.is.null(undefined)).toEqual(false) + expect(realms.is.null(null)).toEqual(true) + }) + + it(`number`, () => { + expect(realms.is.number(null)).toEqual(false) + expect(realms.is.number(42)).toEqual(true) + }) + + it(`plain object`, () => { + expect(realms.is.object(null)).toEqual(false) + expect(realms.is.object({ a: 1, b: 2, c: 3 })).toEqual(true) + }) + + it(`set`, () => { + expect(realms.is.set(null)).toEqual(false) + expect(realms.is.set(new Set([1, 2, 3]))).toEqual(true) + }) + + it(`string`, () => { + expect(realms.is.string(null)).toEqual(false) + expect(realms.is.string(`value`)).toEqual(true) + }) + + it(`undefined`, () => { + expect(realms.is.undefined(null)).toEqual(false) + expect(realms.is.undefined(undefined)).toEqual(true) + }) +}) + +// Create two test connections and serialize a value across them. +const serialize = async exports => { + const { a, b } = createMockRealms() + + const connection1Promise = Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2Promise = Connection({ + input: b, + output: b, + outputOrigin: `b` + }) + + const [connection1, connection2] = await Promise.all([ + connection1Promise, + connection2Promise + ]) + + connection1.addEventListener(`error`, console.error) + connection2.addEventListener(`error`, console.error) + + const close = () => { + connection1.close() + connection2.close() + } + + return { close, imports: connection2.imports } +} + +describe(`serializes values across realm boundaries`, () => { + it(`creates consistent object references`, async () => { + const object = { a: 1, b: 2, c: 3 } + const exports = [object, object] + const { close, imports } = await serialize(exports) + const [left, right] = imports + expect(left).toBe(right) + close() + }) + + it(`freezes serialized values`, async () => { + const exports = [{ a: 1 }] + const { close, imports } = await serialize(exports) + expect(Object.isFrozen(imports)).toEqual(true) + expect(Object.isFrozen(imports[0])).toEqual(true) + close() + }) + + it(`toJSON`, async () => { + const { a, b } = createMockRealms() + const wat = Symbol(`wat`) + + const exports = { + value: wat, + + toJSON: () => ({ + type: `symbol`, + description: `wat` + }) + } + + const reviver = (key, value) => + value.type === `symbol` && value.description === `wat` ? wat : value + + const connection1Promise = Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2 = await Connection({ + input: b, + output: b, + outputOrigin: `b`, + reviver + }) + + const connection1 = await connection1Promise + expect(connection2.imports).toEqual(wat) + connection1.close() + connection2.close() + }) + + it(`unsupported type`, async () => { + const { a } = createMockRealms() + const exports = Symbol(`description`) + + expectAsync( + Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + ).toBeRejected() + }) + + describe(`types`, () => { + it(`array`, async () => { + const exports = [1, 2, 3] + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`boolean`, async () => { + const exports = true + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`Error`, async () => { + const exports = Error(`message`) + exports.name = `name` + + // Axios adds extra properties to errors. + exports.extra = `extra` + + const { close, imports } = await serialize(exports) + expect(imports instanceof Error).toEqual(true) + expect(imports).toEqual(exports) + close() + }) + + describe(`function`, () => { + it(`has the same arguments length`, async () => { + const exports = (a, b) => a + b + const { close, imports } = await serialize(exports) + expect(imports.length).toEqual(exports.length) + close() + }) + + it(`encodes arguments`, async () => { + const exports = async (a, b) => [a, b] + const { close, imports } = await serialize(exports) + const object = {} + const [serializedA, serializedB] = await imports(object, object) + expect(serializedA).not.toBe(object) + expect(serializedA).toBe(serializedB) + close() + }) + + it(`returns value`, async () => { + const exports = async (a, b) => a + b + const { close, imports } = await serialize(exports) + expect(await imports(1, 2)).toEqual(3) + close() + }) + + it(`allows functions only within exports`, async done => { + const { a, b } = createMockRealms() + const compose = async (f, g) => x => f(g(x)) + + const connection1Promise = Connection({ + exports: compose, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2 = await Connection({ + input: b, + output: b, + outputOrigin: `b` + }) + + const connection1 = await connection1Promise + + connection2.addEventListener(`error`, ({ error }) => { + expect(error.exception.message).toEqual( + `Cannot encode functions outside of exports.` + ) + + connection1.close() + connection2.close() + done() + }) + + connection2.imports(Math.cos, Math.sin) + }) + + it(`consistent identity`, async () => { + const func = async (a, b) => a + b + const exports = [func, func] + const { close, imports } = await serialize(exports) + const [left, right] = imports + expect(left).toBe(right) + close() + }) + + it(`throws exception`, async () => { + const exports = async () => { + throw new Error(`a tantrum`) + } + + const { close, imports } = await serialize(exports) + await expectAsync(imports()).toBeRejected() + close() + }) + }) + + it(`map`, async () => { + const exports = new Map([[`a`, 1], [`b`, 2], [`c`, 3]]) + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`null`, async () => { + const exports = null + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`number`, async () => { + const exports = 42 + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`object`, async () => { + const exports = { a: 1, b: 2, c: 3 } + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`set`, async () => { + const exports = new Set([1, 2, 3]) + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`undefined`, async () => { + const exports = undefined + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + }) +}) + +describe(`sanitize`, () => { + it(`canSerialize`, () => { + expect(internal.canSerialize(42)).toEqual(true) + expect(internal.canSerialize(Symbol(`description`))).toEqual(false) + }) + + it(`removes non-serializable types`, () => { + expect(internal.sanitize({ a: 1, b: Math.sin })).toEqual({ a: 1 }) + expect(internal.sanitize(Math.sin)).toEqual(null) + }) + + it(`is mutable`, () => { + const immutable = Object.freeze({ a: { b: 1 } }) + const mutable = internal.sanitize(immutable) + mutable.a.b = 2 + expect(mutable.a.b).toEqual(2) + }) + + it(`error`, () => { + const error = Object.assign(new Error(`message`), { + answer: 42, + sin: Math.sin + }) + + const sanitized = internal.sanitize(error) + expect(sanitized instanceof Error).toEqual(true) + + expect(Object.entries(sanitized)).toEqual( + Object.entries(Object.assign(new Error(`message`), { answer: 42 })) + ) + }) + + it(`sanitizes functions`, async () => { + const trickyFunction = async value => ({ + ...value, + sin: Math.sin + }) + + expect( + await internal.sanitizeFunction(trickyFunction)({ + answer: 42, + cos: Math.cos + }) + ).toEqual({ answer: 42 }) + }) +}) diff --git a/packages/web-microkernel/src/index.js b/packages/web-microkernel/src/index.js new file mode 100644 index 00000000000..924eaed8bcd --- /dev/null +++ b/packages/web-microkernel/src/index.js @@ -0,0 +1,4 @@ +import RealmConnection, { sanitizeFunction } from './RealmConnection' +import multiplexRealm from './multiplexRealm' + +export { multiplexRealm, RealmConnection, sanitizeFunction } diff --git a/packages/web-microkernel/src/lodash-es b/packages/web-microkernel/src/lodash-es new file mode 120000 index 00000000000..6cafb512b62 --- /dev/null +++ b/packages/web-microkernel/src/lodash-es @@ -0,0 +1 @@ +../../../node_modules/lodash-es \ No newline at end of file diff --git a/packages/web-microkernel/src/multiplexRealm.js b/packages/web-microkernel/src/multiplexRealm.js new file mode 100644 index 00000000000..38d2179f284 --- /dev/null +++ b/packages/web-microkernel/src/multiplexRealm.js @@ -0,0 +1,39 @@ +// Multiplex `postMessage` and the `message` event so that multiple agents can +// share the communication channel between realms. +export default ({ tag }, realm) => { + const listeners = new Map() + + return { + addEventListener: (event, listener, useCapture = false) => { + // Multiplex only message events. + if (event === `message`) { + const multiplexListener = ({ data }) => { + // Listen only to appropriately tagged messages and ignore the rest. + if (data.type === tag) { + return listener({ data: data.data }) + } + } + + // Remember this listener so that we can remove it later. + listeners.set(listener, multiplexListener) + + return realm.addEventListener(`message`, multiplexListener, useCapture) + } else { + return realm.addEventListener(event, listener, useCapture) + } + }, + + postMessage: (message, targetOrigin) => + realm.postMessage({ type: tag, data: message }, targetOrigin), + + removeEventListener: (event, listener) => { + if (event === `message`) { + const result = realm.removeEventListener(event, listeners.get(listener)) + listeners.delete(listener) + return result + } else { + return realm.removeEventListener(event, listener) + } + } + } +} diff --git a/packages/web-microkernel/src/multiplexRealm.test.js b/packages/web-microkernel/src/multiplexRealm.test.js new file mode 100644 index 00000000000..f4bde5f6d70 --- /dev/null +++ b/packages/web-microkernel/src/multiplexRealm.test.js @@ -0,0 +1,103 @@ +import multiplexRealm from './multiplexRealm.js' + +// a cheap exercise of the destructured clone operation that occurs between +// realm boundaries +const DestructuredClone = () => { + const resolves = [] + + window.addEventListener(`message`, ({ data }) => { + const resolve = resolves.shift() + resolve(data) + }) + + return value => + new Promise(resolve => { + resolves.push(resolve) + window.postMessage(value) + }) +} + +const destructuredClone = DestructuredClone() + +const MockRealm = origin => { + let target + + return Object.assign(new EventTarget(), { + // Connect to another realm. + connect: realm => { + target = realm + }, + + postMessage: async (message, targetOrigin) => { + console.log(`postMessage`, message) + expect(targetOrigin).toEqual(origin) + + if (target) { + const event = new MessageEvent(`message`, { + data: await destructuredClone(message) + }) + + target.dispatchEvent(event) + } + } + }) +} + +const createMockRealms = () => { + const a = MockRealm(`a`) + const b = MockRealm(`b`) + a.connect(b) + b.connect(a) + return { a, b } +} + +it(`multiplexes use of a realm with a wrapper`, async () => { + const { a, b } = createMockRealms() + + const sender1 = multiplexRealm({ tag: `multiplexed 1` }, a) + const sender2 = multiplexRealm({ tag: `multiplexed 2` }, a) + const receiver1 = multiplexRealm({ tag: `multiplexed 1` }, b) + const receiver2 = multiplexRealm({ tag: `multiplexed 2` }, b) + + let reject1 + let resolve1 + let reject2 + let resolve2 + + const listener1 = ({ data }) => { + try { + expect(data).toEqual(`multiplexed 1`) + resolve1() + } catch (exception) { + reject1(exception) + } + } + + const listener2 = ({ data }) => { + try { + expect(data).toEqual(`multiplexed 2`) + resolve2() + } catch (exception) { + reject2(exception) + } + } + + const promises = Promise.all([ + new Promise((resolve, reject) => { + resolve1 = resolve + reject1 = reject + }), + new Promise((resolve, reject) => { + resolve2 = resolve + reject2 = reject + }) + ]) + + receiver1.addEventListener(`message`, listener1) + receiver2.addEventListener(`message`, listener2) + sender1.postMessage(`multiplexed 1`, `a`) + sender2.postMessage(`multiplexed 2`, `a`) + await promises + receiver1.removeEventListener(`message`, listener1) + receiver2.removeEventListener(`message`, listener2) +}) diff --git a/packages/web-microkernel/wallaby.js b/packages/web-microkernel/wallaby.js new file mode 100644 index 00000000000..a3d7d7a5b96 --- /dev/null +++ b/packages/web-microkernel/wallaby.js @@ -0,0 +1,7 @@ +module.exports = wallaby => ({ + files: [`src/**/*.js`, `!src/**/*.test.js`], + tests: [`src/**/*.test.js`], + env: { + kind: `chrome` + } +}) diff --git a/packages/web-microkernel/yarn.lock b/packages/web-microkernel/yarn.lock new file mode 100644 index 00000000000..a4651646950 --- /dev/null +++ b/packages/web-microkernel/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +lodash-es@4.17.11: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" + integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== diff --git a/yarn.lock b/yarn.lock index b454ce5e8cc..91b3f70b20c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,13 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@nodeguy/channel@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@nodeguy/channel/-/channel-0.6.5.tgz#c04938f443bacaf8abcbe574ca7c12b802f07808" + integrity sha512-etReylPZCfyWU0dgs4qPAOCWPZ8bFaFNNYJTZ894FMZNHOastw6Bb2F42iDnfWBf0ONkDRWLzEt2YABn//iHZQ== + dependencies: + setimmediate "1.0.5" + "@nodelib/fs.stat@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" @@ -10122,7 +10129,7 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash-es@^4.17.10: +lodash-es@4.17.11, lodash-es@^4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== @@ -13874,7 +13881,7 @@ set-value@^2.0.0: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@1.0.5, setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=