diff --git a/package.json b/package.json index 49519b6b..a75327d0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "decimal.js": "^10.2.1", "formik": "^2.2.6", "google-protobuf": "^3.15.8", - "ldk": "^0.4.0", + "ldk": "0.4.3", "lodash.debounce": "^4.0.8", "lottie-web": "^5.7.8", "marina-provider": "^1.4.5", @@ -47,7 +47,7 @@ "readable-stream": "^3.6.0", "redux": "^4.1.0", "redux-persist": "^6.0.0", - "redux-thunk": "^2.3.0", + "redux-saga": "^1.1.3", "stream-browserify": "^3.0.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.1.2", "taxi-protobuf": "vulpemventures/taxi-protobuf", diff --git a/public/manifest.json b/public/manifest.json index e5d1556a..6107958e 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -4,10 +4,10 @@ "manifest_version": 2, "description": "Liquid Wallet Web Extension", "permissions": [ - "alarms", "storage", "unlimitedStorage", "idle", + "alarms", "" ], "background": { diff --git a/src/application/constants/cypress.ts b/src/application/constants/cypress.ts index 8e0dcf12..704dc218 100644 --- a/src/application/constants/cypress.ts +++ b/src/application/constants/cypress.ts @@ -1,18 +1,26 @@ +import { initialRestorerOpts, MnemonicAccountData } from '../../domain/account'; +import { PasswordHash } from '../../domain/password-hash'; + +export const testPasswordHash: PasswordHash = + '8314b7cd43641c00d3389128fe0d8ff2c286d5bb42313fe88da1d6c88a60f48e'; + /** * test wallet data using to skip the onboarding step for Cypress. * data: * | mnemonic = "gas muscle wonder talk sand length swap immense critic opera tree fatigue" * | pwd = marinatest */ -export const testWalletData = { - restorerOpts: {}, +export const testWalletData: MnemonicAccountData = { + restorerOpts: { + liquid: initialRestorerOpts, + testnet: initialRestorerOpts, + regtest: initialRestorerOpts, + }, encryptedMnemonic: 'f343ad95c7be4b07b213ea489d6135b3fb7d659dfb4c9dc2ee9c9e7202100043b5fba308dd2f5d23cd3061452b644653b7c33d79704261feaefd220e9ef9a39784d593bb887f484dccd85b1eb7d53aba', masterXPub: 'vpub5SLqN2bLY4WeYFQ5AFRZPCrhemcgnMPFCcM3L4aepayNa38B7xfjtfan5mNJevzBuUWA98y1CWab2L8dpefgywg3D7dvuNtY1X9UjUKgHvC', masterBlindingKey: 'd4422429e8f06ba093524b31b0ef6d69e2d26e0dd87fade4ab5c875fba2e85d1', - passwordHash: '8314b7cd43641c00d3389128fe0d8ff2c286d5bb42313fe88da1d6c88a60f48e', - confidentialAddresses: [], }; export const testAppURL = 'https://vulpemventures.github.io/marina-api-test-app/'; diff --git a/src/application/redux/actions/action-types.ts b/src/application/redux/actions/action-types.ts index 839f3bec..4d9275ab 100644 --- a/src/application/redux/actions/action-types.ts +++ b/src/application/redux/actions/action-types.ts @@ -1,17 +1,18 @@ // Wallet export const WALLET_SET_DATA = 'WALLET_SET_DATA'; +export const SET_RESTORER_OPTS = 'SET_RESTORER_OPTS'; export const ADD_UTXO = 'ADD_UTXO'; export const DELETE_UTXO = 'DELETE_UTXO'; export const FLUSH_UTXOS = 'FLUSH_UTXOS'; -export const UPDATE_UTXOS = 'UPDATE_UTXOS'; export const SET_DEEP_RESTORER_GAP_LIMIT = 'SET_DEEP_RESTORER_GAP_LIMIT'; export const SET_DEEP_RESTORER_IS_LOADING = 'SET_DEEP_RESTORER_IS_LOADING'; export const SET_DEEP_RESTORER_ERROR = 'SET_DEEP_RESTORER_ERROR'; -export const NEW_ADDRESS_SUCCESS = 'NEW_ADDRESS_SUCCESS'; -export const NEW_CHANGE_ADDRESS_SUCCESS = 'NEW_CHANGE_ADDRESS_SUCCESS'; +export const INCREMENT_EXTERNAL_ADDRESS_INDEX = 'INCREMENT_EXTERNAL_ADDRESS_INDEX'; +export const INCREMENT_INTERNAL_ADDRESS_INDEX = 'INCREMENT_INTERNAL_ADDRESS_INDEX'; export const SET_VERIFIED = 'SET_VERIFIED'; export const RESET_WALLET = 'RESET_WALLET'; -export const SET_UPDATER_LOADER = 'SET_UPDATER_LOADER'; +export const POP_UPDATER_LOADER = 'POP_UPDATER_LOADER'; +export const PUSH_UPDATER_LOADER = 'PUSH_UPDATER_LOADER'; // App export const AUTHENTICATION_SUCCESS = 'AUTHENTICATION_SUCCESS'; @@ -26,13 +27,13 @@ export const RESET_APP = 'RESET_APP'; export const ONBOARDING_SET_MNEMONIC_AND_PASSWORD = 'ONBOARDING_SET_MNEMONIC_AND_PASSWORD'; export const ONBOARDING_FLUSH = 'ONBOARDING_FLUSH'; export const ONBOARDING_SET_IS_FROM_POPUP_FLOW = 'ONBOARDING_SET_IS_FROM_POPUP_FLOW'; +export const ONBOARDING_SET_VERIFIED = 'ONBOARDING_SET_VERIFIED'; // Transactions history -export const UPDATE_TXS = 'UPDATE_TXS'; export const ADD_TX = 'ADD_TX'; -export const RESET_TXS = 'RESET_TXS'; // Pending transaction +export const PENDING_TX_SET_STEP = 'PENDING_TX_SET_STEP'; export const PENDING_TX_SET_ASSET = 'PENDING_TX_SET_ASSET'; export const PENDING_TX_SET_ADDRESSES_AND_AMOUNT = 'PENDING_TX_SET_ADDRESSES_AND_AMOUNT'; export const PENDING_TX_SET_FEE_CHANGE_ADDRESS = 'PENDING_TX_SET_FEE_CHANGE_ADDRESS'; @@ -52,6 +53,7 @@ export const SET_MSG = 'SET_MSG'; export const FLUSH_MSG = 'FLUSH_MSG'; export const SELECT_HOSTNAME = 'SELECT_HOSTNAME'; export const FLUSH_SELECTED_HOSTNAME = 'FLUSH_SELECTED_HOSTNAME'; +export const SET_APPROVE_REQUEST_PARAM = 'SET_APPROVE_REQUEST_PARAM'; export const RESET_CONNECT = 'RESET_CONNECT'; // Taxi @@ -59,11 +61,11 @@ export const SET_TAXI_ASSETS = 'SET_TAXI_ASSETS'; export const UPDATE_TAXI_ASSETS = 'UPDATE_TAXI_ASSETS'; export const RESET_TAXI = 'RESET_TAXI'; -// Alarms -export const START_PERIODIC_UPDATE = 'START_PERIODIC_UPDATE'; - // Restoration export const START_DEEP_RESTORATION = 'START_DEEP_RESTORATION'; // Reset export const RESET = 'RESET'; + +// Updater taskes +export const UPDATE_TASK = 'UPDATE_TASK'; diff --git a/src/application/redux/actions/app.ts b/src/application/redux/actions/app.ts index 4d7ef64d..ae03ddb0 100644 --- a/src/application/redux/actions/app.ts +++ b/src/application/redux/actions/app.ts @@ -4,7 +4,6 @@ import { ONBOARDING_COMPLETETED, LOGOUT_SUCCESS, CHANGE_NETWORK_SUCCESS, - START_PERIODIC_UPDATE, SET_EXPLORER, RESET, } from './action-types'; @@ -13,6 +12,7 @@ import { Password } from '../../../domain/password'; import { match, PasswordHash } from '../../../domain/password-hash'; import { ExplorerURLs } from '../../../domain/app'; import { NetworkString } from 'ldk'; +import { INVALID_PASSWORD_ERROR } from '../../utils/constants'; export const setExplorer = (explorer: ExplorerURLs, network: NetworkString): AnyAction => ({ type: SET_EXPLORER, @@ -26,7 +26,10 @@ export const onboardingCompleted = (): AnyAction => ({ export function logIn(password: Password, passwordHash: PasswordHash): AnyAction { try { if (!match(password, passwordHash)) { - return { type: AUTHENTICATION_FAILURE, payload: { error: new Error('Invalid password') } }; + return { + type: AUTHENTICATION_FAILURE, + payload: { error: new Error(INVALID_PASSWORD_ERROR) }, + }; } return { type: AUTHENTICATION_SUCCESS }; @@ -43,10 +46,6 @@ export function changeNetwork(network: NetworkString): AnyAction { return { type: CHANGE_NETWORK_SUCCESS, payload: { network } }; } -export function startPeriodicUpdate(): AnyAction { - return { type: START_PERIODIC_UPDATE }; -} - export function reset(): AnyAction { return { type: RESET }; } diff --git a/src/application/redux/actions/onboarding.ts b/src/application/redux/actions/onboarding.ts index 28f95b57..5cbcab37 100644 --- a/src/application/redux/actions/onboarding.ts +++ b/src/application/redux/actions/onboarding.ts @@ -2,13 +2,18 @@ import { ONBOARDING_FLUSH, ONBOARDING_SET_MNEMONIC_AND_PASSWORD, ONBOARDING_SET_IS_FROM_POPUP_FLOW, + ONBOARDING_SET_VERIFIED, } from './action-types'; import { AnyAction } from 'redux'; -export function setPasswordAndOnboardingMnemonic(password: string, mnemonic: string): AnyAction { +export function setPasswordAndOnboardingMnemonic( + password: string, + mnemonic: string, + needSecurityAccount: boolean +): AnyAction { return { type: ONBOARDING_SET_MNEMONIC_AND_PASSWORD, - payload: { mnemonic, password }, + payload: { mnemonic, password, needSecurityAccount }, }; } @@ -22,3 +27,14 @@ export function setBackup(mnemonic: string): AnyAction { payload: { mnemonic }, }; } + +// Onboarding Verified is a temporary state for verification of the secret mnemonic +// It aims to flag the new wallet as verified if +// (1) the user has already backed up the mnemonic via onboarding/wallet-confirm +// (2) the user is restoring a wallet via onboarding/wallet-restore +// then `wallet.isVerified` is set to `onboarding.verified` during onboarding /end-of-flow confirm step +export function setOnboardingVerified(): AnyAction { + return { + type: ONBOARDING_SET_VERIFIED, + }; +} diff --git a/src/application/redux/actions/taxi.ts b/src/application/redux/actions/taxi.ts index f139852d..abc562eb 100644 --- a/src/application/redux/actions/taxi.ts +++ b/src/application/redux/actions/taxi.ts @@ -1,10 +1,11 @@ +import { NetworkString } from 'ldk'; import { AnyAction } from 'redux'; import { SET_TAXI_ASSETS, UPDATE_TAXI_ASSETS } from './action-types'; -export function setTaxiAssets(newAssets: string[]): AnyAction { +export function setTaxiAssets(network: NetworkString, newAssets: string[]): AnyAction { return { type: SET_TAXI_ASSETS, - payload: newAssets, + payload: { network, assets: newAssets }, }; } diff --git a/src/application/redux/actions/transaction.ts b/src/application/redux/actions/transaction.ts index e94e775a..21d25985 100644 --- a/src/application/redux/actions/transaction.ts +++ b/src/application/redux/actions/transaction.ts @@ -4,27 +4,40 @@ import { PENDING_TX_SET_ADDRESSES_AND_AMOUNT, PENDING_TX_SET_FEE_CHANGE_ADDRESS, PENDING_TX_SET_FEE_AMOUNT_AND_ASSET, - UPDATE_TXS, PENDING_TX_SET_PSET, + PENDING_TX_SET_STEP, ADD_TX, } from './action-types'; import { AnyAction } from 'redux'; import { Address } from '../../../domain/address'; import { TxDisplayInterface } from '../../../domain/transaction'; -import { NetworkString } from 'ldk'; +import { NetworkString, UnblindedOutput, TxInterface } from 'ldk'; +import { AccountID } from '../../../domain/account'; +import { ActionWithPayload } from '../../../domain/common'; +import { PendingTxStep } from '../reducers/transaction-reducer'; + +export type AddTxAction = ActionWithPayload<{ + tx: TxInterface; + network: NetworkString; + accountID: AccountID; +}>; export function setAsset(asset: string): AnyAction { return { type: PENDING_TX_SET_ASSET, payload: { asset } }; } +export function setPendingTxStep(step: PendingTxStep): AnyAction { + return { type: PENDING_TX_SET_STEP, payload: { step } }; +} + export function setAddressesAndAmount( - receipientAddress: Address, - changeAddress: Address, - amountInSatoshi: number + amountInSatoshi: number, + changeAddresses?: Address[], + recipientAddress?: Address ): AnyAction { return { type: PENDING_TX_SET_ADDRESSES_AND_AMOUNT, - payload: { receipientAddress, changeAddress, amountInSatoshi }, + payload: { recipientAddress, changeAddresses, amountInSatoshi }, }; } @@ -40,22 +53,20 @@ export function flushPendingTx(): AnyAction { return { type: PENDING_TX_FLUSH }; } -export function updateTxs(): AnyAction { - return { - type: UPDATE_TXS, - }; -} - -export function setPset(pset: string): AnyAction { +export function setPset(pset: string, utxos: UnblindedOutput[]): AnyAction { return { type: PENDING_TX_SET_PSET, - payload: { pset }, + payload: { pset, utxos }, }; } -export function addTx(tx: TxDisplayInterface, network: NetworkString): AnyAction { +export function addTx( + accountID: AccountID, + tx: TxDisplayInterface, + network: NetworkString +): AnyAction { return { type: ADD_TX, - payload: { tx, network }, + payload: { tx, network, accountID }, }; } diff --git a/src/application/redux/actions/updater.ts b/src/application/redux/actions/updater.ts new file mode 100644 index 00000000..a6054597 --- /dev/null +++ b/src/application/redux/actions/updater.ts @@ -0,0 +1,14 @@ +import { NetworkString } from 'ldk'; +import { AccountID } from '../../../domain/account'; +import { ActionWithPayload } from '../../../domain/common'; +import { UPDATE_TASK } from './action-types'; + +export type UpdateTaskAction = ActionWithPayload<{ accountID: AccountID; network: NetworkString }>; + +export const updateTaskAction = ( + accountID: AccountID, + network: NetworkString +): UpdateTaskAction => ({ + type: UPDATE_TASK, + payload: { accountID, network }, +}); diff --git a/src/application/redux/actions/utxos.ts b/src/application/redux/actions/utxos.ts index ee5eee05..47c4c197 100644 --- a/src/application/redux/actions/utxos.ts +++ b/src/application/redux/actions/utxos.ts @@ -1,19 +1,32 @@ -import { UnblindedOutput } from 'ldk'; +import { NetworkString, UnblindedOutput } from 'ldk'; import { AnyAction } from 'redux'; -import { ADD_UTXO, DELETE_UTXO, FLUSH_UTXOS, UPDATE_UTXOS } from './action-types'; +import { AccountID } from '../../../domain/account'; +import { ActionWithPayload } from '../../../domain/common'; +import { ADD_UTXO, DELETE_UTXO, FLUSH_UTXOS } from './action-types'; -export function updateUtxos(): AnyAction { - return { type: UPDATE_UTXOS }; -} +export type AddUtxoAction = ActionWithPayload<{ + accountID: AccountID; + utxo: UnblindedOutput; + network: NetworkString; +}>; -export function addUtxo(utxo: UnblindedOutput): AnyAction { - return { type: ADD_UTXO, payload: { utxo } }; +export function addUtxo( + accountID: AccountID, + utxo: UnblindedOutput, + network: NetworkString +): AddUtxoAction { + return { type: ADD_UTXO, payload: { accountID, utxo, network } }; } -export function deleteUtxo(txid: string, vout: number): AnyAction { - return { type: DELETE_UTXO, payload: { txid, vout } }; +export function deleteUtxo( + accountID: AccountID, + txid: string, + vout: number, + network: NetworkString +): AnyAction { + return { type: DELETE_UTXO, payload: { txid, vout, accountID, network } }; } -export function flushUtxos(): AnyAction { - return { type: FLUSH_UTXOS }; +export function flushUtxos(accountID: AccountID, network: NetworkString): AnyAction { + return { type: FLUSH_UTXOS, payload: { accountID, network } }; } diff --git a/src/application/redux/actions/wallet.ts b/src/application/redux/actions/wallet.ts index 41ba08e4..21a0063a 100644 --- a/src/application/redux/actions/wallet.ts +++ b/src/application/redux/actions/wallet.ts @@ -4,27 +4,49 @@ import { SET_DEEP_RESTORER_GAP_LIMIT, SET_DEEP_RESTORER_ERROR, START_DEEP_RESTORATION, - NEW_ADDRESS_SUCCESS, - NEW_CHANGE_ADDRESS_SUCCESS, + INCREMENT_EXTERNAL_ADDRESS_INDEX, + INCREMENT_INTERNAL_ADDRESS_INDEX, SET_VERIFIED, - SET_UPDATER_LOADER, + SET_RESTORER_OPTS, + POP_UPDATER_LOADER, + PUSH_UPDATER_LOADER, } from './action-types'; import { AnyAction } from 'redux'; -import { WalletData } from '../../utils/wallet'; +import { AccountID, MnemonicAccountData } from '../../../domain/account'; +import { NetworkString, StateRestorerOpts } from 'ldk'; +import { PasswordHash } from '../../../domain/password-hash'; -export function setWalletData(walletData: WalletData): AnyAction { +// this action is using during onboarding end-of-flow in order to set up the initial main account state + password hash +export function setWalletData( + walletData: MnemonicAccountData, + passwordHash: PasswordHash +): AnyAction { return { type: WALLET_SET_DATA, - payload: walletData, + payload: { walletData, passwordHash }, }; } -export function incrementAddressIndex(): AnyAction { - return { type: NEW_ADDRESS_SUCCESS }; +export function setRestorerOpts( + accountID: AccountID, + restorerOpts: StateRestorerOpts, + network: NetworkString +): AnyAction { + return { + type: SET_RESTORER_OPTS, + payload: { accountID, restorerOpts, network }, + }; +} + +export function incrementAddressIndex(accountID: AccountID, network: NetworkString): AnyAction { + return { type: INCREMENT_EXTERNAL_ADDRESS_INDEX, payload: { accountID, network } }; } -export function incrementChangeAddressIndex(): AnyAction { - return { type: NEW_CHANGE_ADDRESS_SUCCESS }; +export function incrementChangeAddressIndex( + accountID: AccountID, + network: NetworkString +): AnyAction { + return { type: INCREMENT_INTERNAL_ADDRESS_INDEX, payload: { accountID, network } }; } export function setDeepRestorerIsLoading(isLoading: boolean): AnyAction { @@ -58,12 +80,10 @@ export function setVerified(): AnyAction { return { type: SET_VERIFIED }; } -const setUpdaterLoader = - (loader: string) => - (isLoading: boolean): AnyAction => ({ - type: SET_UPDATER_LOADER, - payload: { loader, isLoading }, - }); +export const popUpdaterLoader = (): AnyAction => ({ + type: POP_UPDATER_LOADER, +}); -export const setUtxosUpdaterLoader = setUpdaterLoader('utxos'); -export const setTransactionsUpdaterLoader = setUpdaterLoader('transactions'); +export const pushUpdaterLoader = (): AnyAction => ({ + type: PUSH_UPDATER_LOADER, +}); diff --git a/src/application/redux/containers/address-amount.container.ts b/src/application/redux/containers/address-amount.container.ts index a932d774..917fc567 100644 --- a/src/application/redux/containers/address-amount.container.ts +++ b/src/application/redux/containers/address-amount.container.ts @@ -1,19 +1,18 @@ import { connect } from 'react-redux'; +import { MainAccountID } from '../../../domain/account'; import { assetGetterFromIAssets } from '../../../domain/assets'; import { RootReducerState } from '../../../domain/common'; import AddressAmountView, { AddressAmountProps, } from '../../../presentation/wallet/send/address-amount'; -import { balancesSelector } from '../selectors/balance.selector'; -import { masterPubKeySelector, restorerOptsSelector } from '../selectors/wallet.selector'; +import { selectBalances } from '../selectors/balance.selector'; +import { selectMainAccount } from '../selectors/wallet.selector'; const mapStateToProps = (state: RootReducerState): AddressAmountProps => ({ + account: selectMainAccount(state), network: state.app.network, transaction: state.transaction, - assets: state.assets, - balances: balancesSelector(state), - masterPubKey: masterPubKeySelector(state), - restorerOpts: restorerOptsSelector(state), + balances: selectBalances(MainAccountID)(state), transactionAsset: assetGetterFromIAssets(state.assets)(state.transaction.sendAsset), }); diff --git a/src/application/redux/containers/choose-fee.container.ts b/src/application/redux/containers/choose-fee.container.ts index 48ab556c..4f5209f9 100644 --- a/src/application/redux/containers/choose-fee.container.ts +++ b/src/application/redux/containers/choose-fee.container.ts @@ -1,23 +1,24 @@ import { connect } from 'react-redux'; +import { MainAccountID } from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; import ChooseFeeView, { ChooseFeeProps } from '../../../presentation/wallet/send/choose-fee'; -import { lbtcAssetByNetwork } from '../../utils'; -import { balancesSelector } from '../selectors/balance.selector'; -import { masterPubKeySelector, restorerOptsSelector } from '../selectors/wallet.selector'; +import { lbtcAssetByNetwork } from '../../utils/network'; +import { selectBalances } from '../selectors/balance.selector'; +import { selectTaxiAssets } from '../selectors/taxi.selector'; +import { selectMainAccount, selectUtxos } from '../selectors/wallet.selector'; const mapStateToProps = (state: RootReducerState): ChooseFeeProps => ({ - wallet: state.wallet, network: state.app.network, assets: state.assets, - balances: balancesSelector(state), - taxiAssets: state.taxi.taxiAssets, + balances: selectBalances(MainAccountID)(state), + taxiAssets: selectTaxiAssets(state), lbtcAssetHash: lbtcAssetByNetwork(state.app.network), - masterPubKey: masterPubKeySelector(state), - restorerOpts: restorerOptsSelector(state), sendAddress: state.transaction.sendAddress, - changeAddress: state.transaction.changeAddress, + changeAddress: state.transaction.changeAddresses[0], sendAsset: state.transaction.sendAsset, sendAmount: state.transaction.sendAmount, + account: selectMainAccount(state), + utxos: selectUtxos(MainAccountID)(state), }); const ChooseFee = connect(mapStateToProps)(ChooseFeeView); diff --git a/src/application/redux/containers/end-of-flow-onboarding.container.ts b/src/application/redux/containers/end-of-flow-onboarding.container.ts index c4989d04..cd44f929 100644 --- a/src/application/redux/containers/end-of-flow-onboarding.container.ts +++ b/src/application/redux/containers/end-of-flow-onboarding.container.ts @@ -14,6 +14,7 @@ const mapStateToProps = (state: RootReducerState): EndOfFlowProps => { network: state.app.network, hasMnemonicRegistered: hasMnemonicSelector(state), explorerURL: selectEsploraURL(state), + walletVerified: state.onboarding.verified, }; }; diff --git a/src/application/redux/containers/end-of-flow.container.ts b/src/application/redux/containers/end-of-flow.container.ts index 07cb8b6c..0e581252 100644 --- a/src/application/redux/containers/end-of-flow.container.ts +++ b/src/application/redux/containers/end-of-flow.container.ts @@ -1,15 +1,17 @@ import { connect } from 'react-redux'; import { RootReducerState } from '../../../domain/common'; import EndOfFlow, { EndOfFlowProps } from '../../../presentation/wallet/send/end-of-flow'; -import { selectEsploraURL } from '../selectors/app.selector'; +import { selectAllAccounts } from '../selectors/wallet.selector'; +import { selectEsploraURL, selectNetwork } from '../selectors/app.selector'; const mapStateToProps = (state: RootReducerState): EndOfFlowProps => ({ - wallet: state.wallet, - network: state.app.network, - restorerOpts: state.wallet.restorerOpts, + accounts: selectAllAccounts(state), pset: state.transaction.pset, explorerURL: selectEsploraURL(state), recipientAddress: state.transaction.sendAddress?.value, + selectedUtxos: state.transaction.selectedUtxos ?? [], + changeAddresses: state.transaction.changeAddresses.map((changeAddress) => changeAddress.value), + network: selectNetwork(state), }); const SendEndOfFlow = connect(mapStateToProps)(EndOfFlow); diff --git a/src/application/redux/containers/home.container.ts b/src/application/redux/containers/home.container.ts index d8a9a64f..96917771 100644 --- a/src/application/redux/containers/home.container.ts +++ b/src/application/redux/containers/home.container.ts @@ -1,18 +1,19 @@ import { RootReducerState } from './../../../domain/common'; import { connect } from 'react-redux'; import HomeView, { HomeProps } from '../../../presentation/wallet/home'; -import { balancesSelector } from '../selectors/balance.selector'; +import { selectBalances } from '../selectors/balance.selector'; import { assetGetterFromIAssets } from '../../../domain/assets'; -import { lbtcAssetByNetwork } from '../../utils'; +import { lbtcAssetByNetwork } from '../../utils/network'; +import { MainAccountID } from '../../../domain/account'; -const mapStateToProps = (state: RootReducerState): HomeProps => ({ - lbtcAssetHash: lbtcAssetByNetwork(state.app.network), - network: state.app.network, - transactionStep: state.transaction.step, - assetsBalance: balancesSelector(state), - getAsset: assetGetterFromIAssets(state.assets), - isWalletVerified: state.wallet.isVerified, -}); +const mapStateToProps = (state: RootReducerState): HomeProps => { + return { + lbtcAssetHash: lbtcAssetByNetwork(state.app.network), + transactionStep: state.transaction.step, + assetsBalance: selectBalances(MainAccountID)(state), + getAsset: assetGetterFromIAssets(state.assets), + }; +}; const Home = connect(mapStateToProps)(HomeView); diff --git a/src/application/redux/containers/receive-select-asset.container.ts b/src/application/redux/containers/receive-select-asset.container.ts index 7dbdea4b..fe26deb9 100644 --- a/src/application/redux/containers/receive-select-asset.container.ts +++ b/src/application/redux/containers/receive-select-asset.container.ts @@ -1,13 +1,14 @@ import { connect } from 'react-redux'; +import { MainAccountID } from '../../../domain/account'; import { assetGetterFromIAssets } from '../../../domain/assets'; import { RootReducerState } from '../../../domain/common'; import ReceiveSelectAssetView, { ReceiveSelectAssetProps, } from '../../../presentation/wallet/receive/receive-select-asset'; -import { balancesSelector } from '../selectors/balance.selector'; +import { selectBalances } from '../selectors/balance.selector'; const mapStateToProps = (state: RootReducerState): ReceiveSelectAssetProps => { - const balances = balancesSelector(state); + const balances = selectBalances(MainAccountID)(state); const getAsset = assetGetterFromIAssets(state.assets); return { network: state.app.network, diff --git a/src/application/redux/containers/receive.container.ts b/src/application/redux/containers/receive.container.ts deleted file mode 100644 index 528ec620..00000000 --- a/src/application/redux/containers/receive.container.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { RootReducerState } from '../../../domain/common'; -import ReceiveView, { ReceiveProps } from '../../../presentation/wallet/receive'; -import { masterPubKeySelector, restorerOptsSelector } from '../selectors/wallet.selector'; - -const mapStateToProps = (state: RootReducerState): ReceiveProps => ({ - pubKey: masterPubKeySelector(state), - restorerOpts: restorerOptsSelector(state), -}); - -const Receive = connect(mapStateToProps)(ReceiveView); - -export default Receive; diff --git a/src/application/redux/containers/send-select-asset.container.ts b/src/application/redux/containers/send-select-asset.container.ts index b9ce2281..66e3cbaf 100644 --- a/src/application/redux/containers/send-select-asset.container.ts +++ b/src/application/redux/containers/send-select-asset.container.ts @@ -1,16 +1,16 @@ import { connect } from 'react-redux'; +import { MainAccountID } from '../../../domain/account'; import { assetGetterFromIAssets } from '../../../domain/assets'; import { RootReducerState } from '../../../domain/common'; import SendSelectAssetView, { SendSelectAssetProps, } from '../../../presentation/wallet/send/send-select-asset'; -import { balancesSelector } from '../selectors/balance.selector'; +import { selectBalances } from '../selectors/balance.selector'; const mapStateToProps = (state: RootReducerState): SendSelectAssetProps => { - const balances = balancesSelector(state); + const balances = selectBalances(MainAccountID)(state); const getAsset = assetGetterFromIAssets(state.assets); return { - network: state.app.network, balanceAssets: Object.keys(balances).map(getAsset), balances, }; diff --git a/src/application/redux/containers/settings-networks.container.ts b/src/application/redux/containers/settings-networks.container.ts index 92dd39cd..7a6050a9 100644 --- a/src/application/redux/containers/settings-networks.container.ts +++ b/src/application/redux/containers/settings-networks.container.ts @@ -7,7 +7,6 @@ import { RootReducerState } from '../../../domain/common'; const mapStateToProps = (state: RootReducerState): SettingsNetworksProps => ({ restorationLoading: state.wallet.deepRestorer.isLoading, error: state.wallet.deepRestorer.error, - updaterIsloading: state.wallet.updaterLoaders.utxos || state.wallet.updaterLoaders.txs, }); const SettingsNetworks = connect(mapStateToProps)(SettingsNetworksView); diff --git a/src/application/redux/containers/transactions.container.ts b/src/application/redux/containers/transactions.container.ts index a64b3ec6..81ddb141 100644 --- a/src/application/redux/containers/transactions.container.ts +++ b/src/application/redux/containers/transactions.container.ts @@ -1,13 +1,15 @@ import { connect } from 'react-redux'; +import { MainAccountID } from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; import TransactionsView, { TransactionsProps } from '../../../presentation/wallet/transactions'; -import { selectElectrsURL } from '../selectors/app.selector'; -import { walletTransactions } from '../selectors/transaction.selector'; +import { selectElectrsURL, selectNetwork } from '../selectors/app.selector'; +import { selectTransactions } from '../selectors/wallet.selector'; const mapStateToProps = (state: RootReducerState): TransactionsProps => ({ assets: state.assets, - transactions: walletTransactions(state), + transactions: selectTransactions(MainAccountID)(state), webExplorerURL: selectElectrsURL(state), + network: selectNetwork(state), isWalletVerified: state.wallet.isVerified, }); diff --git a/src/application/redux/reducers/index.ts b/src/application/redux/reducers/index.ts index 7f10ebd2..a177e677 100644 --- a/src/application/redux/reducers/index.ts +++ b/src/application/redux/reducers/index.ts @@ -1,22 +1,21 @@ import { assetInitState, assetReducer } from './asset-reducer'; import { onboardingReducer } from './onboarding-reducer'; import { transactionReducer, TransactionState, transactionInitState } from './transaction-reducer'; -import { txsHistoryReducer, txsHistoryInitState } from './txs-history-reducer'; import { AnyAction, combineReducers, Reducer } from 'redux'; import { PersistMigrate, Storage } from 'redux-persist'; import { parse, stringify } from '../../utils/browser-storage-converters'; import browser from 'webextension-polyfill'; import persistReducer, { PersistPartial } from 'redux-persist/es/persistReducer'; import { IApp } from '../../../domain/app'; -import { TxsHistoryByNetwork } from '../../../domain/transaction'; -import { IWallet } from '../../../domain/wallet'; +import { WalletState } from '../../../domain/wallet'; import { taxiReducer, TaxiState, taxiInitState } from './taxi-reducer'; import { ConnectData } from '../../../domain/connect'; import { IAssets } from '../../../domain/assets'; import { PersistConfig } from 'redux-persist/lib/types'; import { appReducer, appInitState } from './app-reducer'; -import { walletInitState, walletReducer } from './wallet-reducer'; +import { walletReducer } from './wallet-reducer'; import { connectDataReducer, connectDataInitState } from './connect-data-reducer'; +import { walletMigrate } from '../../../domain/migrations'; const browserLocalStorage: Storage = { getItem: async (key: string) => { @@ -100,24 +99,18 @@ const marinaReducer = combineReducers({ version: 1, migrate: migrateAfter(transactionInitState), }), - txsHistory: persist({ - reducer: txsHistoryReducer, - key: 'txsHistory', - version: 2, - migrate: migrateAfter(txsHistoryInitState), - }), - wallet: persist({ + wallet: persist({ reducer: walletReducer, key: 'wallet', blacklist: ['deepRestorer', 'updaterLoaders'], - version: 1, - migrate: migrateAfter(walletInitState), + version: 2, + migrate: walletMigrate, }), taxi: persist({ reducer: taxiReducer, key: 'taxi', - version: 1, - migrate: migrateAfter(taxiInitState), + version: 2, + migrate: migrateBefore(taxiInitState), }), connect: persist({ reducer: connectDataReducer, diff --git a/src/application/redux/reducers/onboarding-reducer.ts b/src/application/redux/reducers/onboarding-reducer.ts index 58c99ace..d85f7139 100644 --- a/src/application/redux/reducers/onboarding-reducer.ts +++ b/src/application/redux/reducers/onboarding-reducer.ts @@ -5,12 +5,14 @@ export interface OnboardingState { mnemonic: string; password: string; isFromPopupFlow: boolean; + verified: boolean; } const onboardingInitState: OnboardingState = { mnemonic: '', password: '', isFromPopupFlow: false, + verified: false, }; export function onboardingReducer( @@ -37,6 +39,14 @@ export function onboardingReducer( isFromPopupFlow: true, }; } + + case ACTION_TYPES.ONBOARDING_SET_VERIFIED: { + return { + ...state, + verified: true, + }; + } + default: return state; } diff --git a/src/application/redux/reducers/taxi-reducer.ts b/src/application/redux/reducers/taxi-reducer.ts index 8b906487..79027a33 100644 --- a/src/application/redux/reducers/taxi-reducer.ts +++ b/src/application/redux/reducers/taxi-reducer.ts @@ -1,12 +1,17 @@ +import { NetworkString } from 'ldk'; import { AnyAction } from 'redux'; import { RESET_TAXI, SET_TAXI_ASSETS } from '../actions/action-types'; export interface TaxiState { - taxiAssets: string[]; + taxiAssets: Record; } export const taxiInitState: TaxiState = { - taxiAssets: [], + taxiAssets: { + liquid: [], + testnet: [], + regtest: [], + }, }; export function taxiReducer( @@ -19,7 +24,13 @@ export function taxiReducer( } case SET_TAXI_ASSETS: - return { ...state, taxiAssets: payload }; + return { + ...state, + taxiAssets: { + ...state.taxiAssets, + [payload.network]: payload.assets, + }, + }; default: return state; diff --git a/src/application/redux/reducers/transaction-reducer.ts b/src/application/redux/reducers/transaction-reducer.ts index 4e41941b..742235ac 100644 --- a/src/application/redux/reducers/transaction-reducer.ts +++ b/src/application/redux/reducers/transaction-reducer.ts @@ -1,6 +1,7 @@ import * as ACTION_TYPES from '../actions/action-types'; import { AnyAction } from 'redux'; import { Address } from '../../../domain/address'; +import { UnblindedOutput } from 'ldk'; export type PendingTxStep = 'empty' | 'address-amount' | 'choose-fee' | 'confirmation'; @@ -12,16 +13,15 @@ export interface TransactionState { feeAsset: string; pset?: string; sendAddress?: Address; - changeAddress?: Address; - feeChangeAddress?: Address; + changeAddresses: Address[]; + selectedUtxos?: UnblindedOutput[]; } export const transactionInitState: TransactionState = { step: 'empty', sendAsset: '', sendAddress: undefined, - changeAddress: undefined, - feeChangeAddress: undefined, + changeAddresses: [], sendAmount: 0, feeAmount: 0, feeAsset: '', @@ -32,19 +32,21 @@ export function transactionReducer( { type, payload }: AnyAction ): TransactionState { switch (type) { + case ACTION_TYPES.PENDING_TX_SET_STEP: { + return { ...state, step: payload.step }; + } + case ACTION_TYPES.PENDING_TX_SET_ASSET: { return { ...state, - step: 'address-amount', sendAsset: payload.asset, }; } case ACTION_TYPES.PENDING_TX_SET_ADDRESSES_AND_AMOUNT: { return { ...state, - step: 'choose-fee', - sendAddress: payload.receipientAddress, - changeAddress: payload.changeAddress, + sendAddress: payload.recipientAddress, + changeAddresses: payload.changeAddresses, sendAmount: payload.amountInSatoshi, }; } @@ -52,7 +54,7 @@ export function transactionReducer( case ACTION_TYPES.PENDING_TX_SET_FEE_CHANGE_ADDRESS: { return { ...state, - feeChangeAddress: payload.feeChangeAddress, + changeAddresses: [...state.changeAddresses, payload.feeChangeAddress], }; } @@ -71,8 +73,8 @@ export function transactionReducer( case ACTION_TYPES.PENDING_TX_SET_PSET: { return { ...state, - step: 'confirmation', pset: payload.pset, + selectedUtxos: payload.utxos, }; } diff --git a/src/application/redux/reducers/txs-history-reducer.ts b/src/application/redux/reducers/txs-history-reducer.ts deleted file mode 100644 index 690bb7d1..00000000 --- a/src/application/redux/reducers/txs-history-reducer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as ACTION_TYPES from '../actions/action-types'; -import { TxDisplayInterface, TxsHistoryByNetwork } from '../../../domain/transaction'; -import { AnyAction } from 'redux'; -import { NetworkString } from 'ldk'; - -export const txsHistoryInitState: TxsHistoryByNetwork = { regtest: {}, liquid: {}, testnet: {} }; - -export function txsHistoryReducer( - state: TxsHistoryByNetwork = txsHistoryInitState, - { type, payload }: AnyAction -): TxsHistoryByNetwork { - switch (type) { - case ACTION_TYPES.RESET_TXS: { - return txsHistoryInitState; - } - - case ACTION_TYPES.ADD_TX: { - const toAddTx = payload.tx as TxDisplayInterface; - const net = payload.network as NetworkString; - return { ...state, [net]: { ...state[net], [toAddTx.txId]: toAddTx } }; - } - - default: - return state; - } -} diff --git a/src/application/redux/reducers/wallet-reducer.ts b/src/application/redux/reducers/wallet-reducer.ts index d999540c..e3cedb4c 100644 --- a/src/application/redux/reducers/wallet-reducer.ts +++ b/src/application/redux/reducers/wallet-reducer.ts @@ -1,92 +1,185 @@ /* eslint-disable @typescript-eslint/restrict-plus-operands */ import { toStringOutpoint } from './../../utils/utxos'; import * as ACTION_TYPES from '../actions/action-types'; -import { IWallet } from '../../../domain/wallet'; +import { WalletState } from '../../../domain/wallet'; import { AnyAction } from 'redux'; -import { UnblindedOutput } from 'ldk'; +import { AccountID, initialRestorerOpts, MainAccountID } from '../../../domain/account'; +import { newEmptyUtxosAndTxsHistory, TxDisplayInterface } from '../../../domain/transaction'; +import { NetworkString, UnblindedOutput } from 'ldk'; -export const walletInitState: IWallet = { - restorerOpts: { - lastUsedExternalIndex: 0, - lastUsedInternalIndex: 0, +export const walletInitState: WalletState = { + [MainAccountID]: { + encryptedMnemonic: '', + masterBlindingKey: '', + masterXPub: '', + restorerOpts: { + liquid: initialRestorerOpts, + testnet: initialRestorerOpts, + regtest: initialRestorerOpts, + }, + }, + unspentsAndTransactions: { + [MainAccountID]: newEmptyUtxosAndTxsHistory(), }, - encryptedMnemonic: '', - masterXPub: '', - masterBlindingKey: '', passwordHash: '', - utxoMap: {}, deepRestorer: { gapLimit: 20, isLoading: false, }, - updaterLoaders: { - utxos: false, - txs: false, - }, + updaterLoaders: 0, isVerified: false, }; +const addUnspent = + (state: WalletState) => + (accountID: AccountID, utxo: UnblindedOutput, network: NetworkString): WalletState => { + return { + ...state, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + [network]: { + ...state.unspentsAndTransactions[accountID][network], + utxosMap: { + ...state.unspentsAndTransactions[accountID][network].utxosMap, + [toStringOutpoint(utxo)]: utxo, + }, + }, + }, + }, + }; + }; + +const addTx = + (state: WalletState) => + (accountID: AccountID, tx: TxDisplayInterface, network: NetworkString): WalletState => { + return { + ...state, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + [network]: { + ...state.unspentsAndTransactions[accountID][network], + transactions: { + ...state.unspentsAndTransactions[accountID][network].transactions, + [tx.txId]: tx, + }, + }, + }, + }, + }; + }; + export function walletReducer( - state: IWallet = walletInitState, + state: WalletState = walletInitState, { type, payload }: AnyAction -): IWallet { +): WalletState { switch (type) { case ACTION_TYPES.RESET_WALLET: { return walletInitState; } - case ACTION_TYPES.WALLET_SET_DATA: { + case ACTION_TYPES.SET_RESTORER_OPTS: { + const accountID = payload.accountID as AccountID; + const network = payload.network as NetworkString; return { ...state, - masterXPub: payload.masterXPub, - masterBlindingKey: payload.masterBlindingKey, - encryptedMnemonic: payload.encryptedMnemonic, - passwordHash: payload.passwordHash, - restorerOpts: payload.restorerOpts, + [accountID]: { + ...state[accountID], + restorerOpts: { + ...state[accountID].restorerOpts, + [network]: payload.restorerOpts, + }, + }, }; } - case ACTION_TYPES.NEW_CHANGE_ADDRESS_SUCCESS: { + case ACTION_TYPES.WALLET_SET_DATA: { return { ...state, - restorerOpts: { - ...state.restorerOpts, - lastUsedInternalIndex: increment(state.restorerOpts.lastUsedInternalIndex), - }, + passwordHash: payload.passwordHash, + mainAccount: { ...payload.walletData }, }; } - case ACTION_TYPES.NEW_ADDRESS_SUCCESS: { + case ACTION_TYPES.INCREMENT_INTERNAL_ADDRESS_INDEX: { + const accountID = payload.accountID as AccountID; + const network = payload.network as NetworkString; return { ...state, - restorerOpts: { - ...state.restorerOpts, - lastUsedExternalIndex: increment(state.restorerOpts.lastUsedExternalIndex), + [accountID]: { + ...state[accountID], + restorerOpts: { + ...state[accountID]?.restorerOpts, + [network]: { + ...state[accountID]?.restorerOpts[network], + lastUsedInternalIndex: increment( + state[accountID]?.restorerOpts[network]?.lastUsedInternalIndex + ), + }, + }, }, }; } - case ACTION_TYPES.ADD_UTXO: { + case ACTION_TYPES.INCREMENT_EXTERNAL_ADDRESS_INDEX: { + const accountID = payload.accountID as AccountID; + const network = payload.network as NetworkString; return { ...state, - utxoMap: { - ...state.utxoMap, - [toStringOutpoint(payload.utxo as UnblindedOutput)]: payload.utxo, + [accountID]: { + ...state[accountID], + restorerOpts: { + ...state[accountID]?.restorerOpts, + [network]: { + ...state[accountID]?.restorerOpts[network], + lastUsedExternalIndex: increment( + state[accountID]?.restorerOpts[network]?.lastUsedExternalIndex + ), + }, + }, }, }; } + case ACTION_TYPES.ADD_UTXO: { + return addUnspent(state)(payload.accountID, payload.utxo, payload.network); + } + case ACTION_TYPES.DELETE_UTXO: { + const accountID = payload.accountID as AccountID; + if (!state.unspentsAndTransactions[accountID]) { + return state; + } + + const net = payload.network as NetworkString; + const { [toStringOutpoint({ txid: payload.txid, vout: payload.vout })]: deleted, - ...utxoMap - } = state.utxoMap; + ...utxosMap + } = state.unspentsAndTransactions[accountID][net].utxosMap; + return { ...state, - utxoMap, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [payload.accountID]: { + ...state.unspentsAndTransactions[accountID], + [net]: { + ...state.unspentsAndTransactions[accountID][net], + utxosMap, + }, + }, + }, }; } + case ACTION_TYPES.ADD_TX: { + return addTx(state)(payload.accountID, payload.tx, payload.network); + } + case ACTION_TYPES.SET_DEEP_RESTORER_GAP_LIMIT: { return { ...state, @@ -109,9 +202,20 @@ export function walletReducer( } case ACTION_TYPES.FLUSH_UTXOS: { + const accountID = payload.accountID as AccountID; + const net = payload.network as NetworkString; return { ...state, - utxoMap: {}, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + [net]: { + ...state.unspentsAndTransactions[accountID][net], + utxosMap: {}, + }, + }, + }, }; } @@ -122,13 +226,17 @@ export function walletReducer( }; } - case ACTION_TYPES.SET_UPDATER_LOADER: { + case ACTION_TYPES.PUSH_UPDATER_LOADER: { return { ...state, - updaterLoaders: { - ...state.updaterLoaders, - [payload.loader]: payload.isLoading, - }, + updaterLoaders: neverNegative(state.updaterLoaders + 1), + }; + } + + case ACTION_TYPES.POP_UPDATER_LOADER: { + return { + ...state, + updaterLoaders: neverNegative(state.updaterLoaders - 1), }; } @@ -138,6 +246,11 @@ export function walletReducer( } } +const neverNegative = (n: number) => { + if (n < 0) return 0; + return n; +}; + const increment = (n: number | undefined): number => { if (n === undefined || n === null) return 0; if (n < 0) return 1; // -Infinity = 0, return 0+1=1 diff --git a/src/application/redux/sagas/deep-restorer.ts b/src/application/redux/sagas/deep-restorer.ts new file mode 100644 index 00000000..f6d0ec81 --- /dev/null +++ b/src/application/redux/sagas/deep-restorer.ts @@ -0,0 +1,95 @@ +import { + AddressInterface, + EsploraRestorerOpts, + IdentityInterface, + NetworkString, + Restorer, + StateRestorerOpts, +} from 'ldk'; +import { call, put, takeLeading } from 'redux-saga/effects'; +import { Account, AccountID } from '../../../domain/account'; +import { extractErrorMessage } from '../../../presentation/utils/error'; +import { getStateRestorerOptsFromAddresses } from '../../utils/restorer'; +import { START_DEEP_RESTORATION } from '../actions/action-types'; +import { updateTaskAction } from '../actions/updater'; +import { setDeepRestorerError, setDeepRestorerIsLoading, setRestorerOpts } from '../actions/wallet'; +import { + selectDeepRestorerGapLimit, + selectDeepRestorerIsLoading, +} from '../selectors/wallet.selector'; +import { + newSagaSelector, + SagaGenerator, + selectAccountSaga, + selectAllAccountsIDsSaga, + selectExplorerSaga, + selectNetworkSaga, +} from './utils'; + +function* getDeepRestorerSaga( + account: Account, + network: NetworkString +): SagaGenerator> { + return yield call(() => account.getDeepRestorer(network)); +} + +function* restoreSaga( + restorer: Restorer, + arg: EsploraRestorerOpts +): SagaGenerator { + const restoreAddresses = () => restorer(arg).then((id) => id.getAddresses()); + return yield call(restoreAddresses); +} + +// deep restore an account, i.e recompute addresses and try to see if they have received any transaction +// return the new StateRestorerOpts after this restoration +function* deepRestore( + accountID: AccountID, + gapLimit: number, + esploraURL: string, + net: NetworkString +): SagaGenerator { + const account = yield* selectAccountSaga(accountID); + if (!account) throw new Error('Account not found'); + + const restorer = yield* getDeepRestorerSaga(account, net); + const restoredAddresses = yield* restoreSaga(restorer, { gapLimit, esploraURL }); + const stateRestorerOpts = getStateRestorerOptsFromAddresses(restoredAddresses); + return stateRestorerOpts; +} + +const selectDeepRestorerIsLoadingSaga = newSagaSelector(selectDeepRestorerIsLoading); +const selectDeepRestorerGapLimitSaga = newSagaSelector(selectDeepRestorerGapLimit); + +// restoreAllAccounts will launch a deep restore for each account in the redux state +// it will restore the account for the current selected network (the one set in redux state) +// then it will update the restorerOpts in the state after restoration +function* restoreAllAccounts(): SagaGenerator { + const isRunning = yield* selectDeepRestorerIsLoadingSaga(); + if (isRunning) return; + + yield put(setDeepRestorerIsLoading(true)); + + try { + const gapLimit = yield* selectDeepRestorerGapLimitSaga(); + const esploraURL = yield* selectExplorerSaga(); + const accountsIDs = yield* selectAllAccountsIDsSaga(); + for (const ID of accountsIDs) { + const network = yield* selectNetworkSaga(); + const stateRestorerOpts = yield* deepRestore(ID, gapLimit, esploraURL, network); + yield put(setRestorerOpts(ID, stateRestorerOpts, network)); // update state with new restorerOpts + yield put(updateTaskAction(ID, network)); // update utxos and transactions according to the restored addresses (on network) + } + yield put(setDeepRestorerError(undefined)); + } catch (e) { + yield put(setDeepRestorerError(new Error(extractErrorMessage(e)))); + } finally { + yield put(setDeepRestorerIsLoading(false)); + } +} + +// watch for each START_DEEP_RESTORATION action +// if a restoration is not running: start restore all accounts +export function* watchStartDeepRestorer(): SagaGenerator { + yield takeLeading(START_DEEP_RESTORATION, restoreAllAccounts); +} diff --git a/src/application/redux/sagas/main.ts b/src/application/redux/sagas/main.ts new file mode 100644 index 00000000..2a184061 --- /dev/null +++ b/src/application/redux/sagas/main.ts @@ -0,0 +1,62 @@ +import { call, put, takeLeading, fork, all, AllEffect } from 'redux-saga/effects'; +import { fetchAssetsFromTaxi, taxiURL } from '../../utils/taxi'; +import { + RESET, + RESET_APP, + RESET_CONNECT, + RESET_TAXI, + RESET_WALLET, + UPDATE_TAXI_ASSETS, +} from '../actions/action-types'; +import { setTaxiAssets } from '../actions/taxi'; +import { newSagaSelector, SagaGenerator } from './utils'; +import { updateAfterEachLoginAction, watchUpdateTask } from './updater'; +import { watchStartDeepRestorer } from './deep-restorer'; +import { NetworkString } from 'ldk'; +import { selectTaxiAssetsForNetwork } from '../selectors/taxi.selector'; + +function* fetchAndSetTaxiAssets(): SagaGenerator { + yield* fetchTaxiAssetsForNetwork('liquid'); + yield* fetchTaxiAssetsForNetwork('testnet'); +} + +function* fetchTaxiAssetsForNetwork(network: NetworkString): SagaGenerator { + try { + const assets = yield call(fetchAssetsFromTaxi, taxiURL[network]); + const currentTaxiAssets = yield* newSagaSelector(selectTaxiAssetsForNetwork(network))(); + const sortAndJoin = (a: string[]) => a.sort().join(''); + if (sortAndJoin(assets) !== sortAndJoin(currentTaxiAssets)) { + yield put(setTaxiAssets(network, assets)); + } + } catch (err: unknown) { + console.warn(`fetch taxi assets error: ${(err as Error).message || 'unknown'}`); + // ignore errors + } +} + +// watch for every UPDATE_TAXI_ASSETS actions +// wait that previous update is done before begin the new one +function* watchUpdateTaxi(): SagaGenerator { + yield takeLeading(UPDATE_TAXI_ASSETS, fetchAndSetTaxiAssets); +} + +function* reset(): Generator> { + const actionsTypes = [RESET_APP, RESET_WALLET, RESET_CONNECT, RESET_TAXI]; + yield all(actionsTypes.map((type) => put({ type }))); +} + +// watch for every RESET actions +// run reset saga in order to clean the redux state +function* watchReset(): SagaGenerator { + yield takeLeading(RESET, reset); +} + +function* mainSaga(): SagaGenerator { + yield fork(watchReset); + yield fork(watchUpdateTaxi); + yield fork(watchUpdateTask); + yield fork(updateAfterEachLoginAction); + yield fork(watchStartDeepRestorer); +} + +export default mainSaga; diff --git a/src/application/redux/sagas/updater.ts b/src/application/redux/sagas/updater.ts new file mode 100644 index 00000000..9d331ad6 --- /dev/null +++ b/src/application/redux/sagas/updater.ts @@ -0,0 +1,284 @@ +import { + Outpoint, + fetchAndUnblindUtxosGenerator, + AddressInterface, + TxInterface, + address, + networks, + BlindingKeyGetter, + fetchAndUnblindTxsGenerator, + UnblindedOutput, + NetworkString, + getAsset, +} from 'ldk'; +import { Account, AccountID } from '../../../domain/account'; +import { UtxosAndTxs } from '../../../domain/transaction'; +import { addTx } from '../actions/transaction'; +import { addUtxo, AddUtxoAction, deleteUtxo } from '../actions/utxos'; +import { selectUnspentsAndTransactions } from '../selectors/wallet.selector'; +import { + createChannel, + newSagaSelector, + processAsyncGenerator, + SagaGenerator, + selectAccountSaga, + selectAllAccountsIDsSaga, + selectAllUnspentsSaga, + selectExplorerSaga, + selectNetworkSaga, +} from './utils'; +import { ADD_UTXO, UPDATE_TASK, AUTHENTICATION_SUCCESS } from '../actions/action-types'; +import { Asset } from '../../../domain/assets'; +import axios from 'axios'; +import { RootReducerState } from '../../../domain/common'; +import { addAsset } from '../actions/asset'; +import { updateTaskAction, UpdateTaskAction } from '../actions/updater'; +import { popUpdaterLoader, pushUpdaterLoader } from '../actions/wallet'; +import { Channel } from 'redux-saga'; +import { put, AllEffect, all, take, fork, call, takeLatest } from 'redux-saga/effects'; +import { selectEsploraForNetwork } from '../selectors/app.selector'; +import { toStringOutpoint } from '../../utils/utxos'; +import { toDisplayTransaction } from '../../utils/transaction'; +import { defaultPrecision } from '../../utils/constants'; +import { updateTaxiAssets } from '../actions/taxi'; + +function selectUnspentsAndTransactionsSaga( + accountID: AccountID, + network: NetworkString +): SagaGenerator { + return newSagaSelector(selectUnspentsAndTransactions(accountID, network))(); +} + +const putAddUtxoAction = (accountID: AccountID, net: NetworkString) => + function* (utxo: UnblindedOutput): SagaGenerator { + yield put(addUtxo(accountID, utxo, net)); + }; + +const putDeleteUtxoAction = (accountID: AccountID, net: NetworkString) => + function* (outpoint: Outpoint): SagaGenerator { + yield put(deleteUtxo(accountID, outpoint.txid, outpoint.vout, net)); + }; + +function* getAddressesFromAccount( + account: Account, + network: NetworkString +): SagaGenerator { + const getAddresses = () => + account.getWatchIdentity(network).then((identity) => identity.getAddresses()); + return yield call(getAddresses); +} + +// UtxosUpdater lets to update the utxos state for a given AccountID +// it fetches and unblinds the unspents comming from the explorer +function* utxosUpdater( + accountID: AccountID, + network: NetworkString +): SagaGenerator> { + const account = yield* selectAccountSaga(accountID); + if (!account) return; + const explorerURL = yield* selectExplorerSaga(); + const utxosTransactionsState = yield* selectUnspentsAndTransactionsSaga(accountID, network); + const utxosMap = utxosTransactionsState?.utxosMap ?? {}; + const addresses = yield* getAddressesFromAccount(account, network); + const skippedOutpoints: string[] = []; // for deleting + const utxosGenerator = fetchAndUnblindUtxosGenerator(addresses, explorerURL, (utxo) => { + const outpoint = toStringOutpoint(utxo); + const skip = utxosMap[outpoint] !== undefined; + if (skip) skippedOutpoints.push(toStringOutpoint(utxo)); + return skip; + }); + yield* processAsyncGenerator( + utxosGenerator, + putAddUtxoAction(accountID, network) + ); + + const toDelete = Object.values(utxosMap).filter( + (utxo) => !skippedOutpoints.includes(toStringOutpoint(utxo)) + ); + + for (const utxo of toDelete) { + yield* putDeleteUtxoAction(accountID, network)(utxo); + } +} + +const putAddTxAction = (accountID: AccountID, network: NetworkString, walletScripts: string[]) => + function* (tx: TxInterface) { + yield put( + addTx(accountID, toDisplayTransaction(tx, walletScripts, networks[network]), network) + ); + }; + +// UtxosUpdater lets to update the utxos state for a given AccountID +// it fetches and unblinds the unspents comming from the explorer +function* txsUpdater( + accountID: AccountID, + network: NetworkString +): SagaGenerator> { + const account = yield* selectAccountSaga(accountID); + if (!account) return; + const explorerURL = yield* selectExplorerSaga(); + const utxosTransactionsState = yield* selectUnspentsAndTransactionsSaga(accountID, network); + const txsHistory = utxosTransactionsState?.transactions ?? {}; + const addresses = yield* getAddressesFromAccount(account, network); + + const identityBlindKeyGetter: BlindingKeyGetter = (script: string) => { + try { + const addressFromScript = address.fromOutputScript( + Buffer.from(script, 'hex'), + networks[network] + ); + return addresses.find( + (addr) => + address.fromConfidential(addr.confidentialAddress).unconfidentialAddress === + addressFromScript + )?.blindingPrivateKey; + } catch (_) { + return undefined; + } + }; + + const txsGenenerator = fetchAndUnblindTxsGenerator( + addresses.map((a) => a.confidentialAddress), + identityBlindKeyGetter, + explorerURL, + // Check if tx exists in React state, if yes: skip unblinding and fetching + (tx) => txsHistory[tx.txid] !== undefined + ); + + const walletScripts = addresses.map((a) => + address.toOutputScript(a.confidentialAddress).toString('hex') + ); + + yield* processAsyncGenerator( + txsGenenerator, + putAddTxAction(accountID, network, walletScripts) + ); +} + +function* updateTxsAndUtxos( + accountID: AccountID, + network: NetworkString +): Generator, void, any> { + yield all([txsUpdater(accountID, network), utxosUpdater(accountID, network)]); +} + +function* requestAssetInfoFromEsplora( + assetHash: string, + network: NetworkString +): SagaGenerator { + const explorerForNetwork = selectEsploraForNetwork(network); + const getRequest = () => + axios.get(`${explorerForNetwork}/asset/${assetHash}`).then((r) => r.data); + const result = yield call(getRequest); + + return { + name: result?.name ?? 'Unknown', + ticker: result?.ticker ?? assetHash.slice(0, 4).toUpperCase(), + precision: result?.precision ?? defaultPrecision, + }; +} + +function* updaterWorker( + chanToListen: Channel +): SagaGenerator { + while (true) { + const { accountID, network } = yield take(chanToListen); + try { + yield put(pushUpdaterLoader()); + yield* updateTxsAndUtxos(accountID, network); + } finally { + yield put(popUpdaterLoader()); + } + } +} + +const selectAssetSaga = (assetHash: string) => + newSagaSelector((state: RootReducerState) => state.assets[assetHash]); + +const selectAllAssetsSaga = newSagaSelector( + (state: RootReducerState) => new Set(Object.keys(state.assets)) +); + +function* needUpdate(assetHash: string): SagaGenerator { + const assets = yield* selectAllAssetsSaga(); + if (!assets.has(assetHash)) return true; // fetch if the asset is not in the state + const asset = yield* selectAssetSaga(assetHash)(); + if (!asset) return true; // fetch if the asset is undefined + if (asset.ticker === assetHash.slice(0, 4).toUpperCase()) return true; // fetch if the ticker is not in the state + return false; +} + +function* assetsWorker( + assetsChan: Channel<{ assetHash: string; network: NetworkString }> +): SagaGenerator { + while (true) { + const { assetHash, network } = yield take(assetsChan); + if (yield* needUpdate(assetHash)) { + try { + const asset = yield* requestAssetInfoFromEsplora(assetHash, network); + yield put(addAsset(assetHash, asset)); + } catch (e) { + console.warn(`Error fetching asset ${assetHash}`, e); + } + } + } +} + +function updateUtxoAssets(assetsChan: Channel<{ assetHash: string; network: NetworkString }>) { + return function* () { + const utxos = yield* selectAllUnspentsSaga(); + const assets = new Set(utxos.map(getAsset)); + const network = yield* selectNetworkSaga(); + + for (const assetHash of assets) { + yield put(assetsChan, { assetHash, network }); + } + }; +} + +export function* watchForAddUtxoAction( + chan: Channel<{ assetHash: string; network: NetworkString }> +): SagaGenerator { + while (true) { + const action = yield take(ADD_UTXO); + const asset = getAsset(action.payload.utxo); + if (asset) { + yield put(chan, { assetHash: asset, network: action.payload.network }); + } + } +} + +// starts a set of workers in order to handle asynchronously the UPDATE_TASK action +export function* watchUpdateTask(): SagaGenerator { + const MAX_UPDATER_WORKERS = 3; + const accountToUpdateChan = yield* createChannel(); + + for (let i = 0; i < MAX_UPDATER_WORKERS; i++) { + yield fork(updaterWorker, accountToUpdateChan); + } + + // start the asset updater + const assetsChan = yield* createChannel<{ assetHash: string; network: NetworkString }>(); + yield fork(assetsWorker, assetsChan); + yield fork(watchForAddUtxoAction, assetsChan); // this will fee the assets chan + + // listen for UPDATE_TASK + while (true) { + const { payload } = yield take(UPDATE_TASK); + yield fork(updateUtxoAssets(assetsChan)); + yield put(accountToUpdateChan, payload); + } +} + +// starts an update for all accounts after each AUTHENTICATION_SUCCESS action +// only updates the accounts for the current network +export function* updateAfterEachLoginAction(): SagaGenerator { + yield takeLatest(AUTHENTICATION_SUCCESS, function* () { + const accountsID = yield* selectAllAccountsIDsSaga(); + const network = yield* selectNetworkSaga(); + for (const ID of accountsID) { + yield put(updateTaxiAssets()); + yield put(updateTaskAction(ID, network)); + } + }); +} diff --git a/src/application/redux/sagas/utils.ts b/src/application/redux/sagas/utils.ts new file mode 100644 index 00000000..6a6e3279 --- /dev/null +++ b/src/application/redux/sagas/utils.ts @@ -0,0 +1,88 @@ +import { StrictEffect, select, call } from 'redux-saga/effects'; +import { Account, AccountID, MainAccountID } from '../../../domain/account'; +import { RootReducerState } from '../../../domain/common'; +import { + selectEsploraForNetwork, + selectEsploraURL, + selectNetwork, +} from '../selectors/app.selector'; +import { + selectAccount, + selectAllAccountsIDs, + selectUpdaterIsLoading, + selectUtxos, +} from '../selectors/wallet.selector'; +import { isBufferLike, reviver } from '../../utils/browser-storage-converters'; +import { NetworkString } from 'ldk'; +import { Channel, channel, buffers } from 'redux-saga'; + +export type SagaGenerator = Generator< + StrictEffect, + ReturnType, + YieldType +>; + +// customSagaParser is a recursive function that parses the result of a saga selector +// this is a trick due to the fact that the state requested by saga is parsed by JSON.parse +// which does not include our custom Buffer serialization format @see browser-storage-converters.ts file. +function customSagaParser(obj: any): any { + if (typeof obj === 'object') { + if (isBufferLike(obj)) { + return reviver('', obj); + } + + for (const key in obj) { + // eslint-disable-next-line no-prototype-builtins + if (obj.hasOwnProperty(key)) { + obj[key] = customSagaParser(obj[key]); + } + } + } + + return obj; +} + +// create a saga "selector" (a generator) from a redux selector function +export function newSagaSelector(selectorFn: (state: RootReducerState) => R) { + return function* (): SagaGenerator { + const result = yield select(selectorFn); + return customSagaParser(result); + }; +} + +// redux-saga does not handle async generator +// this is useful to pass through this limitation +export function* processAsyncGenerator( + asyncGenerator: AsyncGenerator, + onNext: (n: NextType) => SagaGenerator, + onDone?: () => SagaGenerator +): SagaGenerator> { + const next = () => asyncGenerator.next(); + let n = yield call(next); + while (!n.done) { + yield* onNext(n.value); + n = yield call(next); + } + + if (onDone && n.done) { + yield* onDone(); + } +} + +export function* createChannel(): SagaGenerator> { + return yield call(channel, buffers.sliding(10)); +} + +export const selectNetworkSaga = newSagaSelector(selectNetwork); +export const selectAllAccountsIDsSaga = newSagaSelector(selectAllAccountsIDs); +export const selectExplorerSaga = newSagaSelector(selectEsploraURL); +export const selectUpdaterIsLoadingSaga = newSagaSelector(selectUpdaterIsLoading); +export const selectAllUnspentsSaga = newSagaSelector(selectUtxos(MainAccountID)); + +export function selectAccountSaga(accountID: AccountID): SagaGenerator { + return newSagaSelector(selectAccount(accountID))(); +} + +export function selectExplorerSagaForNet(net: NetworkString): SagaGenerator { + return newSagaSelector(selectEsploraForNetwork(net))(); +} diff --git a/src/application/redux/selectors/app.selector.ts b/src/application/redux/selectors/app.selector.ts index 704cae36..f2b967d3 100644 --- a/src/application/redux/selectors/app.selector.ts +++ b/src/application/redux/selectors/app.selector.ts @@ -1,14 +1,20 @@ +import { NetworkString } from 'ldk'; import { ExplorerURLs } from '../../../domain/app'; import { appInitState } from '../reducers/app-reducer'; import { RootReducerState } from './../../../domain/common'; -function getExplorerURLSelector(state: RootReducerState): ExplorerURLs { +function getExplorerURLSelector(state: RootReducerState, net?: NetworkString): ExplorerURLs { return ( - state.app.explorerByNetwork[state.app.network] ?? - appInitState.explorerByNetwork[state.app.network] + state.app.explorerByNetwork[net ?? state.app.network] ?? + appInitState.explorerByNetwork[net ?? state.app.network] ); } +export const selectEsploraForNetwork = (network: NetworkString) => + function (state: RootReducerState): string { + return getExplorerURLSelector(state, network).esploraURL; + }; + export function selectEsploraURL(state: RootReducerState): string { return getExplorerURLSelector(state).esploraURL; } @@ -16,3 +22,7 @@ export function selectEsploraURL(state: RootReducerState): string { export function selectElectrsURL(state: RootReducerState): string { return getExplorerURLSelector(state).electrsURL; } + +export function selectNetwork(state: RootReducerState) { + return state.app.network; +} diff --git a/src/application/redux/selectors/balance.selector.ts b/src/application/redux/selectors/balance.selector.ts index bdb4c101..cb94b897 100644 --- a/src/application/redux/selectors/balance.selector.ts +++ b/src/application/redux/selectors/balance.selector.ts @@ -1,21 +1,48 @@ import { balances } from 'ldk'; +import { AccountID } from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; -import { lbtcAssetByNetwork } from '../../utils'; +import { sumBalances } from '../../utils/balances'; +import { lbtcAssetByNetwork } from '../../utils/network'; +import { selectTransactions, selectUtxos } from './wallet.selector'; export type BalancesByAsset = { [assetHash: string]: number }; + +export const selectBalances = (...accounts: AccountID[]) => { + const selectors = accounts.map((id) => selectBalancesForAccount(id)); + return (state: RootReducerState) => { + return sumBalances(...selectors.map((select) => select(state))); + }; +}; + /** * Extract balances from all unblinded utxos in state * @param onSuccess * @param onError */ -export function balancesSelector(state: RootReducerState): BalancesByAsset { - const utxos = Object.values(state.wallet.utxoMap); - const balancesFromUtxos = balances(utxos); +const selectBalancesForAccount = + (accountID: AccountID) => + (state: RootReducerState): BalancesByAsset => { + const utxos = selectUtxos(accountID)(state); + const balancesFromUtxos = balances(utxos); + + const txs = selectTransactions(accountID)(state); + const assets = Object.keys(balancesFromUtxos); + + for (const tx of txs) { + const allTxAssets = tx.transfers.map((t) => t.asset); + for (const a of allTxAssets) { + if (!assets.includes(a)) { + balancesFromUtxos[a] = 0; + assets.push(a); + } + } + } + + const lbtcAssetHash = lbtcAssetByNetwork(state.app.network); - const lbtcAssetHash = lbtcAssetByNetwork(state.app.network); - if (!Object.prototype.hasOwnProperty.call(balancesFromUtxos, lbtcAssetHash)) { - balancesFromUtxos[lbtcAssetHash] = 0; - } + if (balancesFromUtxos[lbtcAssetHash] === undefined) { + balancesFromUtxos[lbtcAssetHash] = 0; + } - return balancesFromUtxos; -} + return balancesFromUtxos; + }; diff --git a/src/application/redux/selectors/taxi.selector.ts b/src/application/redux/selectors/taxi.selector.ts new file mode 100644 index 00000000..14cf7686 --- /dev/null +++ b/src/application/redux/selectors/taxi.selector.ts @@ -0,0 +1,10 @@ +import { NetworkString } from 'ldk'; +import { RootReducerState } from '../../../domain/common'; + +export function selectTaxiAssets(state: RootReducerState): string[] { + return state.taxi.taxiAssets[state.app.network]; +} + +export function selectTaxiAssetsForNetwork(net: NetworkString) { + return (state: RootReducerState) => state.taxi.taxiAssets[net]; +} diff --git a/src/application/redux/selectors/transaction.selector.ts b/src/application/redux/selectors/transaction.selector.ts index d742b06b..57c922bc 100644 --- a/src/application/redux/selectors/transaction.selector.ts +++ b/src/application/redux/selectors/transaction.selector.ts @@ -1,26 +1,7 @@ -import { RootReducerState } from '../../../domain/common'; import { TxDisplayInterface } from '../../../domain/transaction'; -export function walletTransactions(state: RootReducerState): TxDisplayInterface[] { - return Object.values(state.txsHistory[state.app.network]); -} - export const txHasAsset = (assetHash: string) => (tx: TxDisplayInterface): boolean => { return tx.transfers.map((t) => t.asset).includes(assetHash); }; - -export function getOutputsAddresses(state: RootReducerState): string[] { - const txState = state.transaction; - const addresses = [txState.changeAddress, txState.feeChangeAddress, txState.sendAddress]; - - const result: string[] = []; - for (const addr of addresses) { - if (addr) { - result.push(addr.value); - } - } - - return result; -} diff --git a/src/application/redux/selectors/wallet.selector.ts b/src/application/redux/selectors/wallet.selector.ts index 758089b7..47d3c90b 100644 --- a/src/application/redux/selectors/wallet.selector.ts +++ b/src/application/redux/selectors/wallet.selector.ts @@ -1,33 +1,106 @@ -import { IdentityType, MasterPublicKey, StateRestorerOpts, UnblindedOutput } from 'ldk'; +import { + AccountID, + createMnemonicAccount, + MnemonicAccount, + MainAccountID, + Account, +} from '../../../domain/account'; +import { MasterPublicKey, NetworkString, UnblindedOutput } from 'ldk'; import { RootReducerState } from '../../../domain/common'; +import { TxDisplayInterface, UtxosAndTxs } from '../../../domain/transaction'; -export function masterPubKeySelector(state: RootReducerState): MasterPublicKey { - const { masterBlindingKey, masterXPub } = state.wallet; - const network = state.app.network; - const pubKeyWallet = new MasterPublicKey({ - chain: network, - type: IdentityType.MasterPublicKey, - opts: { - masterPublicKey: masterXPub, - masterBlindingKey: masterBlindingKey, - }, - }); - - return pubKeyWallet; +export function masterPubKeySelector(state: RootReducerState): Promise { + return selectMainAccount(state).getWatchIdentity(state.app.network); } -export function restorerOptsSelector(state: RootReducerState): StateRestorerOpts { - return state.wallet.restorerOpts; -} +export const selectUtxos = + (...accounts: AccountID[]) => + (state: RootReducerState): UnblindedOutput[] => { + return accounts.flatMap((ID) => selectUtxosForAccount(ID)(state)); + }; -export function utxosSelector(state: RootReducerState): UnblindedOutput[] { - return Object.values(state.wallet.utxoMap); -} +const selectUtxosForAccount = + (accountID: AccountID, net?: NetworkString) => + (state: RootReducerState): UnblindedOutput[] => { + const utxos = selectUnspentsAndTransactions( + accountID, + net ?? state.app.network + )(state)?.utxosMap; + if (utxos) { + return Object.values(utxos); + } + return []; + }; + +export const selectTransactions = + (...accounts: AccountID[]) => + (state: RootReducerState) => { + return accounts.flatMap((ID) => selectTransactionsForAccount(ID)(state)); + }; + +const selectTransactionsForAccount = + (accountID: AccountID, net?: NetworkString) => + (state: RootReducerState): TxDisplayInterface[] => { + const txs = selectUnspentsAndTransactions( + accountID, + net ?? state.app.network + )(state)?.transactions; + if (txs) { + return Object.values(txs); + } + return []; + }; export function hasMnemonicSelector(state: RootReducerState): boolean { - return state.wallet.encryptedMnemonic !== '' && state.wallet.encryptedMnemonic !== undefined; + return ( + state.wallet.mainAccount.encryptedMnemonic !== '' && + state.wallet.mainAccount.encryptedMnemonic !== undefined + ); } -export function selectUpdaterLoaders(state: RootReducerState): { utxos: boolean; txs: boolean } { - return state.wallet.updaterLoaders; +export function selectMainAccount(state: RootReducerState): MnemonicAccount { + return createMnemonicAccount(state.wallet.mainAccount); } + +export const selectAllAccounts = (state: RootReducerState): Account[] => { + const mainAccount = selectMainAccount(state); + return [mainAccount]; +}; + +export const selectAllAccountsIDs = (state: RootReducerState): AccountID[] => { + return selectAllAccounts(state).map((account) => account.getAccountID()); +}; + +export const selectAccount = ( + accountID: AccountID +): ((state: RootReducerState) => Account | undefined) => { + if (accountID === MainAccountID) { + return selectMainAccount; + } + + // TODO multiple accounts: we need to modify the way we select account via ID + return () => undefined; +}; + +// By definition, each asset hash should be associated with a single Account +export const selectAccountForAsset = (_: string) => (state: RootReducerState) => { + return selectMainAccount(state); +}; + +export const selectUnspentsAndTransactions = + (accountID: AccountID, network: NetworkString) => + (state: RootReducerState): UtxosAndTxs | undefined => { + return state.wallet.unspentsAndTransactions[accountID][network]; + }; + +export const selectDeepRestorerIsLoading = (state: RootReducerState) => { + return state.wallet.deepRestorer.isLoading; +}; + +export const selectDeepRestorerGapLimit = (state: RootReducerState) => { + return state.wallet.deepRestorer.gapLimit; +}; + +export const selectUpdaterIsLoading = (state: RootReducerState) => { + return state.wallet.updaterLoaders > 0; +}; diff --git a/src/application/redux/store.ts b/src/application/redux/store.ts index 57574a71..23c807f0 100644 --- a/src/application/redux/store.ts +++ b/src/application/redux/store.ts @@ -1,42 +1,23 @@ -import { - RESET, - START_DEEP_RESTORATION, - START_PERIODIC_UPDATE, - UPDATE_TAXI_ASSETS, - UPDATE_TXS, - UPDATE_UTXOS, -} from './actions/action-types'; import { createStore, applyMiddleware, Store } from 'redux'; -import { alias, wrapStore } from 'webext-redux'; +import { wrapStore } from 'webext-redux'; import marinaReducer from './reducers'; -import { - fetchAndSetTaxiAssets, - updateTxsHistory, - fetchAndUpdateUtxos, - startAlarmUpdater, - deepRestorer, - resetAll, -} from '../../background/backend'; import persistStore from 'redux-persist/es/persistStore'; import { parse, stringify } from '../utils/browser-storage-converters'; -import thunk from 'redux-thunk'; +import createSagaMiddleware from 'redux-saga'; +import mainSaga from './sagas/main'; export const serializerAndDeserializer = { serializer: (payload: any) => stringify(payload), deserializer: (payload: any) => parse(payload), }; -const backgroundAliases = { - [UPDATE_UTXOS]: () => fetchAndUpdateUtxos(), - [UPDATE_TXS]: () => updateTxsHistory(), - [UPDATE_TAXI_ASSETS]: () => fetchAndSetTaxiAssets(), - [START_PERIODIC_UPDATE]: () => startAlarmUpdater(), - [START_DEEP_RESTORATION]: () => deepRestorer(), - [RESET]: () => resetAll(), +const create = () => { + const sagaMiddleware = createSagaMiddleware(); + const store = createStore(marinaReducer, applyMiddleware(sagaMiddleware)); + sagaMiddleware.run(mainSaga); + return store; }; -const create = () => createStore(marinaReducer, applyMiddleware(alias(backgroundAliases), thunk)); - export const marinaStore = create(); export const persistor = persistStore(marinaStore); diff --git a/src/application/utils/address.ts b/src/application/utils/address.ts index 3f09c3a8..38984790 100644 --- a/src/application/utils/address.ts +++ b/src/application/utils/address.ts @@ -1,9 +1,5 @@ import { address, networks, NetworkString } from 'ldk'; -export const blindingKeyFromAddress = (addr: string): string => { - return address.fromConfidential(addr).blindingKey.toString('hex'); -}; - export const isConfidentialAddress = (addr: string): boolean => { try { address.fromConfidential(addr); diff --git a/src/application/utils/balances.ts b/src/application/utils/balances.ts new file mode 100644 index 00000000..9e1914ac --- /dev/null +++ b/src/application/utils/balances.ts @@ -0,0 +1,19 @@ +import { BalancesByAsset } from '../redux/selectors/balance.selector'; + +const addBalance = (toAdd: BalancesByAsset) => (base: BalancesByAsset) => { + const result = base; + for (const asset of Object.keys(toAdd)) { + result[asset] = (result[asset] ?? 0) + toAdd[asset]; + } + + return result; +}; + +export const sumBalances = (...balances: BalancesByAsset[]) => { + const [balance, ...rest] = balances; + let result = balance; + const addFns = rest.map(addBalance); + addFns.forEach((f) => (result = f(result))); + + return result; +}; diff --git a/src/application/utils/browser-storage-converters.ts b/src/application/utils/browser-storage-converters.ts index 1812c24c..d4235e91 100644 --- a/src/application/utils/browser-storage-converters.ts +++ b/src/application/utils/browser-storage-converters.ts @@ -46,7 +46,7 @@ export function reviver(key: string, value: any) { return value; } -function isBufferLike(x: any): boolean { +export function isBufferLike(x: any): boolean { return isObject(x) && x.type === 'Buffer' && (isArray(x.data) || isString(x.data)); } diff --git a/src/application/utils/constants.ts b/src/application/utils/constants.ts index 3c197e47..c118d224 100644 --- a/src/application/utils/constants.ts +++ b/src/application/utils/constants.ts @@ -1,7 +1,9 @@ import lightniteAssetsHashes from '../constants/lightnite_asset_hash.json'; import blockstreamAssetHashes from '../constants/blockstream_asset_hash.json'; -import { networks, NetworkString } from 'ldk'; +import { networks } from 'ldk'; +export const INVALID_MNEMONIC_ERROR = 'Invalid mnemonic'; +export const INVALID_PASSWORD_ERROR = 'Invalid password'; export const SOMETHING_WENT_WRONG_ERROR = 'Oops, something went wrong...'; export const feeLevelToSatsPerByte: { [key: string]: number } = { @@ -10,12 +12,6 @@ export const feeLevelToSatsPerByte: { [key: string]: number } = { '100': 0.1, }; -export const taxiURL: Record = { - regtest: 'http://localhost:8000', - testnet: 'https://grpc.liquid.taxi:18000', - liquid: 'https://grpc.liquid.taxi', -}; - const makeImagePath = (fileName: string): string => `assets/images/liquid-assets/${fileName}`; const LBTC_IMG = makeImagePath('liquid-btc.svg'); diff --git a/src/application/utils/crypto.ts b/src/application/utils/crypto.ts index f9b90db4..fcf41ef1 100644 --- a/src/application/utils/crypto.ts +++ b/src/application/utils/crypto.ts @@ -3,6 +3,7 @@ import { createEncryptedMnemonic, EncryptedMnemonic } from '../../domain/encrypt import { createMnemonic, Mnemonic } from '../../domain/mnemonic'; import { Password } from '../../domain/password'; import { createPasswordHash, PasswordHash } from '../../domain/password-hash'; +import { INVALID_PASSWORD_ERROR } from './constants'; const iv = Buffer.alloc(16, 0); export function encrypt(payload: Mnemonic, password: Password): EncryptedMnemonic { @@ -15,12 +16,16 @@ export function encrypt(payload: Mnemonic, password: Password): EncryptedMnemoni } export function decrypt(encrypted: EncryptedMnemonic, password: Password): Mnemonic { - const hash = crypto.createHash('sha1').update(password); - const secret = hash.digest().slice(0, 16); - const key = crypto.createDecipheriv('aes-128-cbc', secret, iv); - let decrypted = key.update(encrypted, 'hex', 'utf8'); - decrypted += key.final('utf8'); - return createMnemonic(decrypted); + try { + const hash = crypto.createHash('sha1').update(password); + const secret = hash.digest().slice(0, 16); + const key = crypto.createDecipheriv('aes-128-cbc', secret, iv); + let decrypted = key.update(encrypted, 'hex', 'utf8'); + decrypted += key.final('utf8'); + return createMnemonic(decrypted); + } catch { + throw new Error(INVALID_PASSWORD_ERROR); + } } export function sha256Hash(str: string): string { diff --git a/src/application/utils/index.ts b/src/application/utils/index.ts deleted file mode 100644 index 83b8a318..00000000 --- a/src/application/utils/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './address'; -export * from './constants'; -export * from './crypto'; -export * from './idle'; -export * from './network'; -export * from './restorer'; -export * from './taxi'; -export * from './transaction'; -export * from './utxos'; diff --git a/src/application/utils/restorer.ts b/src/application/utils/restorer.ts index f9b5c888..a3cb1f41 100644 --- a/src/application/utils/restorer.ts +++ b/src/application/utils/restorer.ts @@ -3,11 +3,21 @@ import { Mnemonic, IdentityType, mnemonicRestorerFromState, + MasterPublicKey, + masterPubKeyRestorerFromState, + CosignerMultisig, + MultisigWatchOnly, + XPub, + restorerFromState, + AddressInterface, NetworkString, } from 'ldk'; -import { Address } from '../../domain/address'; +import { MasterBlindingKey } from '../../domain/master-blinding-key'; +import { MasterXPub } from '../../domain/master-extended-pub'; -export function getStateRestorerOptsFromAddresses(addresses: Address[]): StateRestorerOpts { +export function getStateRestorerOptsFromAddresses( + addresses: AddressInterface[] +): StateRestorerOpts { const derivationPaths = addresses.map((addr) => addr.derivationPath); const indexes = []; @@ -33,16 +43,74 @@ export function getStateRestorerOptsFromAddresses(addresses: Address[]): StateRe }; } -export function mnemonicWallet( +// create a Mnemonic Identity +// restore it from restorer's state +export function restoredMnemonic( mnemonic: string, restorerOpts: StateRestorerOpts, chain: NetworkString ): Promise { - const mnemonicWallet = new Mnemonic({ + const mnemonicID = new Mnemonic({ chain, type: IdentityType.Mnemonic, opts: { mnemonic }, }); - return mnemonicRestorerFromState(mnemonicWallet)(restorerOpts); + return mnemonicRestorerFromState(mnemonicID)(restorerOpts); +} + +// create a MasterPublicKey Identity +// restore it using StateRestorerOpts +export function restoredMasterPublicKey( + masterXPub: MasterXPub, + masterBlindingKey: MasterBlindingKey, + restorerOpts: StateRestorerOpts, + network: NetworkString +) { + const xpub = newMasterPublicKey(masterXPub, masterBlindingKey, network); + return masterPubKeyRestorerFromState(xpub)(restorerOpts); +} + +export function newMasterPublicKey( + masterXPub: MasterXPub, + masterBlindingKey: MasterBlindingKey, + network: NetworkString +) { + return new MasterPublicKey({ + chain: network, + type: IdentityType.MasterPublicKey, + opts: { + masterPublicKey: masterXPub, + masterBlindingKey: masterBlindingKey, + }, + }); +} + +// create a MultisigWatchOnly Identity +// restore it using StateRestorerOpts +export function restoredWatchOnlyMultisig( + signerXPub: XPub, + cosigners: CosignerMultisig[], + requiredSignatures: number, + restorerOpts: StateRestorerOpts, + network: NetworkString +) { + const multisigID = newMultisigWatchOnly(network, requiredSignatures, cosigners, signerXPub); + return restorerFromState(multisigID)(restorerOpts); +} + +export function newMultisigWatchOnly( + network: NetworkString, + requiredSignatures: number, + cosigners: CosignerMultisig[], + signerXPub: XPub +) { + return new MultisigWatchOnly({ + chain: network, + type: IdentityType.MultisigWatchOnly, + opts: { + requiredSignatures, + cosigners: cosigners.concat([signerXPub]), + }, + }); } diff --git a/src/application/utils/taxi.ts b/src/application/utils/taxi.ts index 88abca91..0b8f9d27 100644 --- a/src/application/utils/taxi.ts +++ b/src/application/utils/taxi.ts @@ -1,24 +1,54 @@ -import { TaxiClient } from 'taxi-protobuf/generated/js/TaxiServiceClientPb'; -import { - AssetDetails, - ListAssetsRequest, - TopupWithAssetReply, - TopupWithAssetRequest, -} from 'taxi-protobuf/generated/js/taxi_pb'; +import axios from 'axios'; +import { NetworkString } from 'ldk'; + +interface AssetDetails { + assetHash: string; + assetPrice: number; + basisPoint: number; +} + +export interface Topup { + assetAmount: number; + assetHash: string; + assetSpread: number; + partial: string; + topupId: string; +} + +export interface TopupWithAssetReply { + expiry: number; + privateBlindingKey: string; + publicBlindingKey: string; + topup?: Topup; +} export const fetchAssetsFromTaxi = async (taxiUrl: string): Promise => { - const client = new TaxiClient(taxiUrl, undefined); - const res = await client.listAssets(new ListAssetsRequest(), null); - return res.getAssetsList().map((asset: AssetDetails) => asset.getAssetHash()); + const { data } = await axios.get(`${taxiUrl}/assets`); + return data.assets.map((asset: AssetDetails) => asset.assetHash); }; export const fetchTopupFromTaxi = async ( taxiUrl: string, - asset: string -): Promise => { - const client = new TaxiClient(taxiUrl, undefined); - const request = new TopupWithAssetRequest(); - request.setAssetHash(asset); - const res = await client.topupWithAsset(request, null); - return res.toObject(); + assetHash: string +): Promise => { + const { data } = await axios.post(`${taxiUrl}/asset/topup`, { assetHash }); + // Coerce assetAmount and assetSpread into number + // Temporary fix, waiting for this issue to close: + // https://github.com/vulpemventures/taxi-daemon/issues/91 + if (data?.topup) { + for (const key of ['assetAmount', 'assetSpread']) { + const num = Number(data.topup[key]); + if (Number.isNaN(num) || !Number.isSafeInteger(num)) { + throw new Error(`error coercing topup ${key} ${data.topup[key]} into number`); + } + data.topup[key] = num; + } + } + return data; +}; + +export const taxiURL: Record = { + regtest: 'http://localhost:8000', + testnet: 'https://grpc.liquid.taxi:18000/v1', + liquid: 'https://grpc.liquid.taxi/v1', }; diff --git a/src/application/utils/transaction.ts b/src/application/utils/transaction.ts index f13c853c..eab21a73 100644 --- a/src/application/utils/transaction.ts +++ b/src/application/utils/transaction.ts @@ -1,4 +1,5 @@ import { + address, address as addrLDK, addToTx, ChangeAddressFromAssetGetter, @@ -7,7 +8,6 @@ import { decodePset, getUnblindURLFromTx, greedyCoinSelector, - Mnemonic, psetToUnsignedTx, RecipientInterface, TxInterface, @@ -17,56 +17,132 @@ import { getSats, getAsset, NetworkString, + IdentityInterface, } from 'ldk'; import { confidential, networks, payments, Psbt } from 'liquidjs-lib'; -import { blindingKeyFromAddress, isConfidentialAddress } from './address'; +import { isConfidentialAddress } from './address'; import { Transfer, TxDisplayInterface, TxStatusEnum, TxType } from '../../domain/transaction'; import { Topup } from 'taxi-protobuf/generated/js/taxi_pb'; import { lbtcAssetByNetwork } from './network'; -import { fetchTopupFromTaxi } from './taxi'; -import { taxiURL } from './constants'; +import { fetchTopupFromTaxi, taxiURL } from './taxi'; import { DataRecipient, isAddressRecipient, isDataRecipient, Recipient } from 'marina-provider'; -function outPubKeysMap(pset: string, outputAddresses: string[]): Map { - const outPubkeys: Map = new Map(); +const blindingKeyFromAddress = (addr: string): Buffer => { + return address.fromConfidential(addr).blindingKey; +}; + +function outPubKeysMap(pset: string, outputAddresses: string[]): Map { + const outPubkeys: Map = new Map(); - for (const outAddr of outputAddresses) { - const index = outputIndexFromAddress(pset, outAddr); + for (const outAddress of outputAddresses) { + const index = outputIndexFromAddress(pset, outAddress); if (index === -1) continue; - if (isConfidentialAddress(outAddr)) { - const blindingPublicKey = blindingKeyFromAddress(outAddr); - outPubkeys.set(index, blindingPublicKey); + if (isConfidentialAddress(outAddress)) { + outPubkeys.set(index, blindingKeyFromAddress(outAddress)); } } return outPubkeys; } +/** + * Computes the blinding data map used to blind the pset. + * @param pset the unblinded pset to compute the blinding data map + * @param utxos utxos to use in order to get the blinding data of confidential inputs (not needed for unconfidential ones). + */ +function inputBlindingDataMap( + pset: string, + utxos: UnblindedOutput[] +): Map { + const inputBlindingData = new Map(); + const txidToBuffer = function (txid: string) { + return Buffer.from(txid, 'hex').reverse(); + }; + + let index = -1; + for (const input of psetToUnsignedTx(pset).ins) { + index++; + const utxo = utxos.find((u) => txidToBuffer(u.txid).equals(input.hash)); + + // if the input is confidential, unblindData will be defined + // in that case, we need to add it to the blinding data map + // this let to ignore unconfidential inputs + if (utxo?.unblindData) { + inputBlindingData.set(index, utxo.unblindData); + } + } + + return inputBlindingData; +} + +async function blindPset(psetBase64: string, utxos: UnblindedOutput[], outputAddresses: string[]) { + const outputPubKeys = outPubKeysMap(psetBase64, outputAddresses); + const inputBlindingData = inputBlindingDataMap(psetBase64, utxos); + + return ( + await decodePset(psetBase64).blindOutputsByIndex(inputBlindingData, outputPubKeys) + ).toBase64(); +} + +function isFullyBlinded(psetBase64: string, excludeAddresses: string[]) { + const excludeScripts = excludeAddresses.map((a) => addrLDK.toOutputScript(a)); + const tx = psetToUnsignedTx(psetBase64); + for (const out of tx.outs) { + if (out.script.length > 0 && !excludeScripts.includes(out.script)) { + if (!out.rangeProof || !out.surjectionProof) { + return false; + } + } + } + + return true; +} + /** * Take an unsigned pset, blind it according to recipientAddresses and sign the pset using the mnemonic. - * @param mnemonic Identity using to sign the tx. should be restored. + * @param signerIdentity Identity using to sign the tx. should be restored. * @param psetBase64 the unsign tx. * @param recipientAddresses a list of known recipients addresses (non wallet output addresses). */ export async function blindAndSignPset( - mnemonic: Mnemonic, psetBase64: string, - recipientAddresses: string[] + selectedUtxos: UnblindedOutput[], + identities: IdentityInterface[], + recipientAddresses: string[], + changeAddresses: string[] ): Promise { - const outputAddresses = (await mnemonic.getAddresses()).map((a) => a.confidentialAddress); + const outputAddresses = recipientAddresses.concat(changeAddresses); - const outputPubKeys = outPubKeysMap(psetBase64, outputAddresses.concat(recipientAddresses)); - const outputsToBlind = Array.from(outputPubKeys.keys()); + const blindedPset = await blindPset(psetBase64, selectedUtxos, outputAddresses); + if (!isFullyBlinded(blindedPset, recipientAddresses)) { + throw new Error('blindPSET error: not fully blinded'); + } - const blindedPset: string = await mnemonic.blindPset(psetBase64, outputsToBlind, outputPubKeys); + const signedPset = await signPset(blindedPset, identities); - const signedPset: string = await mnemonic.signPset(blindedPset); + const decodedPset = decodePset(signedPset); + if (!decodedPset.validateSignaturesOfAllInputs()) { + throw new Error('PSET is not fully signed'); + } - const ptx = decodePset(signedPset); - if (!ptx.validateSignaturesOfAllInputs()) { - throw new Error('Transaction containes invalid signatures'); + return decodedPset.finalizeAllInputs().extractTransaction().toHex(); +} + +export async function signPset( + psetBase64: string, + identities: IdentityInterface[] +): Promise { + let pset = psetBase64; + for (const id of identities) { + pset = await id.signPset(pset); + try { + if (decodePset(pset).validateSignaturesOfAllInputs()) break; + } catch { + continue; + } } - return ptx.finalizeAllInputs().extractTransaction().toHex(); + + return pset; } function outputIndexFromAddress(tx: string, addressToFind: string): number { diff --git a/src/application/utils/wallet.ts b/src/application/utils/wallet.ts deleted file mode 100644 index f5e41ea2..00000000 --- a/src/application/utils/wallet.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createMasterXPub, MasterXPub } from '../../domain/master-extended-pub'; -import { EncryptedMnemonic } from '../../domain/encrypted-mnemonic'; -import { Address, createAddress } from '../../domain/address'; -import { - Mnemonic, - IdentityType, - StateRestorerOpts, - mnemonicRestorerFromEsplora, - NetworkString, -} from 'ldk'; -import { PasswordHash } from '../../domain/password-hash'; -import { Mnemonic as Mnemo } from '../../domain/mnemonic'; -import { createMasterBlindingKey, MasterBlindingKey } from '../../domain/master-blinding-key'; -import { Password } from '../../domain/password'; -import { getStateRestorerOptsFromAddresses } from './restorer'; -import { encrypt, hashPassword } from './crypto'; - -export interface WalletData { - encryptedMnemonic: EncryptedMnemonic; - masterXPub: MasterXPub; - masterBlindingKey: MasterBlindingKey; - passwordHash: PasswordHash; - restorerOpts: StateRestorerOpts; - confidentialAddresses: Address[]; -} - -export async function createWalletFromMnemonic( - password: Password, - mnemonic: Mnemo, - chain: NetworkString, - esploraURL: string -): Promise { - const toRestore = new Mnemonic({ - chain, - type: IdentityType.Mnemonic, - opts: { mnemonic }, - }); - - const mnemonicIdentity = await mnemonicRestorerFromEsplora(toRestore)({ - esploraURL, - gapLimit: 20, - }); - const masterXPub = createMasterXPub(mnemonicIdentity.masterPublicKey); - const masterBlindingKey = createMasterBlindingKey(mnemonicIdentity.masterBlindingKey); - const encryptedMnemonic = encrypt(mnemonic, password); - const passwordHash = hashPassword(password); - const addresses = (await mnemonicIdentity.getAddresses()).map((a) => - createAddress(a.confidentialAddress, a.derivationPath) - ); - - return { - restorerOpts: getStateRestorerOptsFromAddresses(addresses), - encryptedMnemonic, - masterXPub, - masterBlindingKey, - passwordHash, - confidentialAddresses: addresses, - }; -} diff --git a/src/background/alarms.ts b/src/background/alarms.ts new file mode 100644 index 00000000..2ce1fd9d --- /dev/null +++ b/src/background/alarms.ts @@ -0,0 +1,47 @@ +import browser from 'webextension-polyfill'; +import { updateTaxiAssets } from '../application/redux/actions/taxi'; +import { updateTaskAction } from '../application/redux/actions/updater'; +import { selectNetwork } from '../application/redux/selectors/app.selector'; +import { + selectAllAccountsIDs, + selectUpdaterIsLoading, +} from '../application/redux/selectors/wallet.selector'; +import { marinaStore } from '../application/redux/store'; + +export enum Alarm { + WalletUpdate = 'ALARM_WALLET_UPDATE', + TaxiUpdate = 'ALARM_TAXI_UPDATE', +} + +// newPeriodicTask create a new alarm + set up a listener for this task +function newPeriodicTask(alarm: Alarm, task: () => void, periodInMinutes: number) { + return () => { + browser.alarms.create(alarm, { periodInMinutes }); + browser.alarms.onAlarm.addListener(({ name }) => { + if (name === alarm) { + task(); + } + }); + }; +} + +function dispatchUpdateTaskForAllAccountsIDs() { + const state = marinaStore.getState(); + const isUpdating = selectUpdaterIsLoading(state); + if (isUpdating) return; // skip if any updater worker is already running + const accountIDs = selectAllAccountsIDs(state); + const network = selectNetwork(state); + const updateTasks = accountIDs.map((id) => updateTaskAction(id, network)); + updateTasks.forEach(marinaStore.dispatch); +} + +function dispatchUpdateTaxiAction() { + marinaStore.dispatch(updateTaxiAssets()); +} + +export const periodicUpdater = newPeriodicTask( + Alarm.WalletUpdate, + dispatchUpdateTaskForAllAccountsIDs, + 1 +); +export const periodicTaxiUpdater = newPeriodicTask(Alarm.TaxiUpdate, dispatchUpdateTaxiAction, 1); diff --git a/src/background/backend.ts b/src/background/backend.ts deleted file mode 100644 index f4f8d984..00000000 --- a/src/background/backend.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { RootReducerState } from '../domain/common'; -import { defaultPrecision } from '../application/utils/constants'; -import axios from 'axios'; -import browser from 'webextension-polyfill'; -import { - address as addressLDK, - networks, - BlindingKeyGetter, - address, - fetchAndUnblindTxsGenerator, - fetchAndUnblindUtxosGenerator, - masterPubKeyRestorerFromEsplora, - MasterPublicKey, - masterPubKeyRestorerFromState, - isUnblindedOutput, - getAsset, -} from 'ldk'; -import { - fetchAssetsFromTaxi, - getStateRestorerOptsFromAddresses, - taxiURL, - toDisplayTransaction, - toStringOutpoint, -} from '../application/utils'; -import { - setDeepRestorerError, - setDeepRestorerIsLoading, - setTransactionsUpdaterLoader, - setUtxosUpdaterLoader, - setWalletData, -} from '../application/redux/actions/wallet'; -import { createAddress } from '../domain/address'; -import { setTaxiAssets, updateTaxiAssets } from '../application/redux/actions/taxi'; -import { - masterPubKeySelector, - restorerOptsSelector, -} from '../application/redux/selectors/wallet.selector'; -import { addUtxo, deleteUtxo, updateUtxos } from '../application/redux/actions/utxos'; -import { addAsset } from '../application/redux/actions/asset'; -import { ThunkAction } from 'redux-thunk'; -import { AnyAction, Dispatch } from 'redux'; -import { IAssets } from '../domain/assets'; -import { addTx, updateTxs } from '../application/redux/actions/transaction'; -import { - RESET_APP, - RESET_CONNECT, - RESET_TAXI, - RESET_TXS, - RESET_WALLET, -} from '../application/redux/actions/action-types'; -import { flushTx } from '../application/redux/actions/connect'; -import { selectEsploraURL } from '../application/redux/selectors/app.selector'; -import { extractErrorMessage } from '../presentation/utils/error'; - -const UPDATE_ALARM = 'UPDATE_ALARM'; - -/** - * fetch and unblind the utxos and then refresh it. - */ -export function fetchAndUpdateUtxos(): ThunkAction { - return async (dispatch, getState) => { - try { - const state = getState(); - const { wallet, app } = state; - if (!app.isAuthenticated) return; - - dispatch(setUtxosUpdaterLoader(true)); - const xpub = await getRestoredXPub(state); - const addrs = (await xpub.getAddresses()).reverse(); - if (addrs.length === 0) return; - - const explorer = selectEsploraURL(getState()); - - const currentOutpoints = Object.values(wallet.utxoMap).map(({ txid, vout }) => ({ - txid, - vout, - })); - - const skippedOutpoints: string[] = []; // for deleting - - // Fetch utxo(s). Return blinded utxo(s) if unblinding has been skipped - const utxos = fetchAndUnblindUtxosGenerator( - addrs, - explorer, - // Skip unblinding if utxo exists in current state - (utxo) => { - const outpoint = toStringOutpoint(utxo); - const skip = wallet.utxoMap[outpoint] !== undefined; - - if (skip) skippedOutpoints.push(toStringOutpoint(utxo)); - - return skip; - } - ); - - let utxoIterator = await utxos.next(); - while (!utxoIterator.done) { - const utxo = utxoIterator.value; - if (isUnblindedOutput(utxo)) { - const assets = getState().assets; - await fetchAssetInfos(getAsset(utxo), explorer, assets, dispatch).catch(console.error); - dispatch(addUtxo(utxo)); - } - utxoIterator = await utxos.next(); - } - - if (utxoIterator.done) { - console.info(`number of utxos fetched: ${utxoIterator.value.numberOfUtxos}`); - if (utxoIterator.value.errors.length > 0) { - console.warn( - `${utxoIterator.value.errors.length} errors occurs during utxos updater generator` - ); - } - } - - for (const outpoint of currentOutpoints) { - if (skippedOutpoints.includes(toStringOutpoint(outpoint))) continue; - // if not skipped, it means the utxo has been spent - dispatch(deleteUtxo(outpoint.txid, outpoint.vout)); - } - } catch (error) { - console.error(`fetchAndUpdateUtxos error: ${error}`); - } finally { - dispatch(setUtxosUpdaterLoader(false)); - } - }; -} - -/** - * fetch the asset infos from explorer (ticker, precision etc...) - */ -async function fetchAssetInfos( - assetHash: string, - explorerUrl: string, - assetsState: IAssets, - dispatch: Dispatch -) { - if (assetsState[assetHash] !== undefined) return; // do not update - - const assetInfos = (await axios.get(`${explorerUrl}/asset/${assetHash}`)).data; - const name = assetInfos?.name ? assetInfos.name : 'Unknown'; - const ticker = assetInfos?.ticker ? assetInfos.ticker : assetHash.slice(0, 4).toUpperCase(); - const precision = assetInfos.precision !== undefined ? assetInfos.precision : defaultPrecision; - - dispatch(addAsset(assetHash, { name, ticker, precision })); -} - -/** - * use fetchAndUnblindTxsGenerator to update the tx history - */ -export function updateTxsHistory(): ThunkAction { - return async (dispatch, getState) => { - try { - const state = getState(); - const { app, txsHistory } = state; - if (!app.isAuthenticated) return; - - dispatch(setTransactionsUpdaterLoader(true)); - // Initialize txs to txsHistory shallow clone - const pubKeyWallet = await getRestoredXPub(state); - const addressInterfaces = (await pubKeyWallet.getAddresses()).reverse(); - const walletScripts = addressInterfaces.map((a) => - address.toOutputScript(a.confidentialAddress).toString('hex') - ); - - const explorer = selectEsploraURL(getState()); - - const identityBlindKeyGetter: BlindingKeyGetter = (script: string) => { - try { - const address = addressLDK.fromOutputScript( - Buffer.from(script, 'hex'), - networks[app.network] - ); - return addressInterfaces.find( - (addr) => - addressLDK.fromConfidential(addr.confidentialAddress).unconfidentialAddress === - address - )?.blindingPrivateKey; - } catch (_) { - return undefined; - } - }; - - const txsGen = fetchAndUnblindTxsGenerator( - addressInterfaces.map((a) => a.confidentialAddress), - identityBlindKeyGetter, - explorer, - // Check if tx exists in React state - (tx) => txsHistory[app.network][tx.txid] !== undefined - ); - - let it = await txsGen.next(); - - // If no new tx already in state then return txsHistory of current network - if (it.done) { - return; - } - - while (!it.done) { - const tx = it.value; - // Update all txsHistory state at each single new tx - const toAdd = toDisplayTransaction(tx, walletScripts, networks[app.network]); - dispatch(addTx(toAdd, app.network)); - it = await txsGen.next(); - } - } catch (error) { - console.error(`fetchAndUnblindTxs: ${error}`); - } finally { - dispatch(setTransactionsUpdaterLoader(false)); - } - }; -} - -/** - * fetch assets from taxi daemon endpoint (make a grpc call) - * and then set assets in store. - */ -export function fetchAndSetTaxiAssets(): ThunkAction { - return async (dispatch, getState) => { - const state = getState(); - const assets = await fetchAssetsFromTaxi(taxiURL[state.app.network]); - - const currentAssets = state.taxi.taxiAssets; - const sortAndJoin = (a: string[]) => a.sort().join(''); - - if (sortAndJoin(currentAssets) === sortAndJoin(assets)) { - return; // skip if same assets state - } - - dispatch(setTaxiAssets(assets)); - }; -} - -// Start the periodic updater (for utxos and txs fetching) -export function startAlarmUpdater(): ThunkAction { - return (dispatch) => { - dispatch(updateUtxos()); - - browser.alarms.onAlarm.addListener((alarm) => { - switch (alarm.name) { - case UPDATE_ALARM: - dispatch(updateTxs()); - dispatch(updateUtxos()); - dispatch(updateTaxiAssets()); - break; - - default: - break; - } - }); - - browser.alarms.create(UPDATE_ALARM, { - when: Date.now(), - periodInMinutes: 1, - }); - }; -} - -// Using to generate addresses and use the explorer to test them -export function deepRestorer(): ThunkAction { - return async (dispatch, getState) => { - const state = getState(); - const { isLoading, gapLimit } = state.wallet.deepRestorer; - const toRestore = masterPubKeySelector(state); - const explorer = selectEsploraURL(getState()); - if (isLoading) return; - - try { - dispatch(setDeepRestorerIsLoading(true)); - const opts = { gapLimit, esploraURL: explorer }; - const publicKey = await masterPubKeyRestorerFromEsplora(toRestore)(opts); - const addresses = (await publicKey.getAddresses()).map((a) => - createAddress(a.confidentialAddress, a.derivationPath) - ); - - const restorerOpts = getStateRestorerOptsFromAddresses(addresses); - - dispatch( - setWalletData({ - ...state.wallet, - restorerOpts, - confidentialAddresses: addresses, - }) - ); - - dispatch(updateUtxos()); - dispatch(updateTxsHistory()); - dispatch(fetchAndSetTaxiAssets()); - - dispatch(setDeepRestorerError(undefined)); - } catch (err) { - dispatch(setDeepRestorerError(new Error(extractErrorMessage(err)))); - } finally { - dispatch(setDeepRestorerIsLoading(false)); - } - }; -} - -function getRestoredXPub(state: RootReducerState): Promise { - const xPubKey = masterPubKeySelector(state); - const opts = restorerOptsSelector(state); - return masterPubKeyRestorerFromState(xPubKey)(opts); -} - -// reset all the reducers except the `assets` reducer (shared data). -export function resetAll(): ThunkAction { - return (dispatch) => { - dispatch({ type: RESET_TAXI }); - dispatch({ type: RESET_TXS }); - dispatch({ type: RESET_APP }); - dispatch({ type: RESET_WALLET }); - dispatch({ type: RESET_CONNECT }); - dispatch(flushTx()); - }; -} diff --git a/src/background/background-script.ts b/src/background/background-script.ts index 26497966..373be44d 100644 --- a/src/background/background-script.ts +++ b/src/background/background-script.ts @@ -1,11 +1,11 @@ import SafeEventEmitter from '@metamask/safe-event-emitter'; import browser from 'webextension-polyfill'; -import { testWalletData } from '../application/constants/cypress'; -import { logOut, onboardingCompleted, startPeriodicUpdate } from '../application/redux/actions/app'; +import { testWalletData, testPasswordHash } from '../application/constants/cypress'; +import { logOut, onboardingCompleted } from '../application/redux/actions/app'; import { enableWebsite } from '../application/redux/actions/connect'; import { setWalletData } from '../application/redux/actions/wallet'; import { marinaStore, wrapMarinaStore } from '../application/redux/store'; -import { IDLE_MESSAGE_TYPE } from '../application/utils'; +import { IDLE_MESSAGE_TYPE } from '../application/utils/idle'; import { tabIsOpen } from '../application/utils/common'; import { setUpPopup } from '../application/utils/popup'; import { @@ -16,6 +16,7 @@ import { } from '../domain/message'; import { POPUP_RESPONSE } from '../presentation/connect/popupBroker'; import { INITIALIZE_WELCOME_ROUTE } from '../presentation/routes/constants'; +import { periodicTaxiUpdater, periodicUpdater } from './alarms'; // MUST be > 15 seconds const IDLE_TIMEOUT_IN_SECONDS = 300; // 5 minutes @@ -35,7 +36,7 @@ browser.runtime.onInstalled.addListener(({ reason }) => { case 'install': { // /!\ skip onboarding in test env if (process.env.NODE_ENV === 'test') { - marinaStore.dispatch(setWalletData(testWalletData)); + marinaStore.dispatch(setWalletData(testWalletData, testPasswordHash)); marinaStore.dispatch(enableWebsite('vulpemventures.github.io', 'regtest')); // skip the enable step too await setUpPopup(); marinaStore.dispatch(onboardingCompleted()); @@ -46,8 +47,16 @@ browser.runtime.onInstalled.addListener(({ reason }) => { break; } case 'update': { - // avoid first click doing nothing after update - if (marinaStore?.getState()?.app?.isOnboardingCompleted) await setUpPopup(); + if (marinaStore?.getState()?.app?.isOnboardingCompleted) { + // After an update, and only if the user is already onboarded, + // we need the setup the popup or the first click on the + // extension icon will do nothing + await setUpPopup(); + // After an update, all previous periodic updaters are lost. + // If the user is already onboarded, we need to re-enable them. + periodicUpdater(); + periodicTaxiUpdater(); + } } } })().catch(console.error); @@ -56,11 +65,12 @@ browser.runtime.onInstalled.addListener(({ reason }) => { // /!\ FIX: prevent opening the onboarding page if the browser has been closed browser.runtime.onStartup.addListener(() => { (async () => { - if (marinaStore.getState().wallet.encryptedMnemonic !== '') { + if (marinaStore.getState().wallet.mainAccount.encryptedMnemonic !== '') { // Everytime the browser starts up we need to set up the popup page await browser.browserAction.setPopup({ popup: 'popup.html' }); - // We also set up the periodic update if the user is onboarded - marinaStore.dispatch(startPeriodicUpdate()); + // We also set up the periodic updaters if the user is onboarded + periodicUpdater(); + periodicTaxiUpdater(); } })().catch(console.error); }); @@ -77,11 +87,12 @@ browser.browserAction.onClicked.addListener(() => { // the wallet creation process, we let user re-open it // Check if wallet exists in storage and if not we open the // onboarding page again. - if (marinaStore.getState().wallet.encryptedMnemonic === '') { + if (marinaStore.getState().wallet.mainAccount.encryptedMnemonic === '') { welcomeTabID = await openInitializeWelcomeRoute(); return; } else { await browser.browserAction.setPopup({ popup: 'popup.html' }); + // Function browser.browserAction.openPopup() exists in Firefox but not in Chrome if (browser.browserAction.openPopup) await browser.browserAction.openPopup(); } })().catch(console.error); diff --git a/src/content/broker.ts b/src/content/broker.ts index 46b8f922..91b7688a 100644 --- a/src/content/broker.ts +++ b/src/content/broker.ts @@ -15,12 +15,15 @@ export type BrokerOption = (broker: Broker) => void; export default class Broker { protected store?: BrokerProxyStore = undefined; protected backgroundScriptPort: browser.Runtime.Port; + protected providerName: string; - constructor(options: BrokerOption[] = []) { + constructor(name: string, options: BrokerOption[] = []) { this.backgroundScriptPort = browser.runtime.connect(); for (const opt of options) { opt(this); } + + this.providerName = name; } start(handler: MessageHandler) { @@ -29,6 +32,7 @@ export default class Broker { 'message', (event: MessageEvent) => { if (!isMessageEvent(event)) return; + if (event.data.provider !== this.providerName) return; // handler should reject and resolve ResponseMessage. handler(event.data) @@ -76,5 +80,7 @@ export default class Broker { // custom type guard for MessageEvent function isMessageEvent(event: MessageEvent): event is MessageEvent { - return event.source === window && event.data && event.data.id && event.data.name; + return ( + event.source === window && event.data && event.data.id && event.data.name && event.data.provider + ); } diff --git a/src/content/content-script.ts b/src/content/content-script.ts index 8becfd5f..87afd168 100644 --- a/src/content/content-script.ts +++ b/src/content/content-script.ts @@ -1,6 +1,6 @@ import browser from 'webextension-polyfill'; -import MarinaBroker from './marinaBroker'; +import MarinaBroker from './marina/marinaBroker'; // start the broker + inject the inject-script.js script startContentScript().catch(console.error); @@ -10,6 +10,7 @@ async function startContentScript() { if (doctypeCheck() && suffixCheck() && documentElementCheck()) { const currentHostname = window.location.hostname; await MarinaBroker.Start(currentHostname); + injectScript(browser.runtime.getURL('inject-script.js')); } } diff --git a/src/content/marinaBroker.ts b/src/content/marina/marinaBroker.ts similarity index 75% rename from src/content/marinaBroker.ts rename to src/content/marina/marinaBroker.ts index 7bef0fa1..51a22d15 100644 --- a/src/content/marinaBroker.ts +++ b/src/content/marina/marinaBroker.ts @@ -1,14 +1,19 @@ -import { stringify } from '../application/utils/browser-storage-converters'; -import { compareCacheForEvents, newCacheFromState, newStoreCache, StoreCache } from './store-cache'; -import Broker, { BrokerOption } from './broker'; +import { stringify } from '../../application/utils/browser-storage-converters'; +import { + compareCacheForEvents, + newCacheFromState, + newStoreCache, + StoreCache, +} from '../store-cache'; +import Broker, { BrokerOption } from '../broker'; import { MessageHandler, newErrorResponseMessage, newSuccessResponseMessage, RequestMessage, -} from '../domain/message'; -import Marina from '../inject/marina'; -import { RootReducerState } from '../domain/common'; +} from '../../domain/message'; +import Marina from '../../inject/marina/provider'; +import { RootReducerState } from '../../domain/common'; import { disableWebsite, flushMsg, @@ -17,26 +22,28 @@ import { setMsg, setTx, setTxData, -} from '../application/redux/actions/connect'; +} from '../../application/redux/actions/connect'; import { - masterPubKeySelector, - restorerOptsSelector, - utxosSelector, -} from '../application/redux/selectors/wallet.selector'; -import { masterPubKeyRestorerFromState, MasterPublicKey, getAsset, getSats } from 'ldk'; + selectMainAccount, + selectTransactions, + selectUtxos, +} from '../../application/redux/selectors/wallet.selector'; import { incrementAddressIndex, incrementChangeAddressIndex, -} from '../application/redux/actions/wallet'; -import { lbtcAssetByNetwork, sortRecipients, broadcastTx } from '../application/utils'; -import { walletTransactions } from '../application/redux/selectors/transaction.selector'; -import { balancesSelector } from '../application/redux/selectors/balance.selector'; -import { assetGetterFromIAssets } from '../domain/assets'; +} from '../../application/redux/actions/wallet'; +import { selectBalances } from '../../application/redux/selectors/balance.selector'; +import { assetGetterFromIAssets } from '../../domain/assets'; import { Balance, Recipient, Utxo } from 'marina-provider'; -import { SignTransactionPopupResponse } from '../presentation/connect/sign-pset'; -import { SpendPopupResponse } from '../presentation/connect/spend'; -import { SignMessagePopupResponse } from '../presentation/connect/sign-msg'; -import { selectEsploraURL } from '../application/redux/selectors/app.selector'; +import { SignTransactionPopupResponse } from '../../presentation/connect/sign-pset'; +import { SpendPopupResponse } from '../../presentation/connect/spend'; +import { SignMessagePopupResponse } from '../../presentation/connect/sign-msg'; +import { MainAccountID } from '../../domain/account'; +import { getAsset, getSats } from 'ldk'; +import { selectEsploraURL, selectNetwork } from '../../application/redux/selectors/app.selector'; +import { broadcastTx, lbtcAssetByNetwork } from '../../application/utils/network'; +import { sortRecipients } from '../../application/utils/transaction'; +import { selectTaxiAssets } from '../../application/redux/selectors/taxi.selector'; export default class MarinaBroker extends Broker { private static NotSetUpError = new Error('proxy store and/or cache are not set up'); @@ -49,7 +56,7 @@ export default class MarinaBroker extends Broker { } private constructor(hostname = '', brokerOpts?: BrokerOption[]) { - super(brokerOpts); + super(Marina.PROVIDER_NAME, brokerOpts); this.hostname = hostname; this.cache = newStoreCache(); this.subscribeToStoreEvents(); @@ -123,23 +130,28 @@ export default class MarinaBroker extends Broker { case Marina.prototype.getAddresses.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const net = selectNetwork(state); + const xpub = await selectMainAccount(state).getWatchIdentity(net); return successMsg(await xpub.getAddresses()); } case Marina.prototype.getNextAddress.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const account = selectMainAccount(state); + const net = selectNetwork(state); + const xpub = await account.getWatchIdentity(net); const nextAddress = await xpub.getNextAddress(); - await this.store.dispatchAsync(incrementAddressIndex()); + await this.store.dispatchAsync(incrementAddressIndex(account.getAccountID(), net)); return successMsg(nextAddress); } case Marina.prototype.getNextChangeAddress.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const account = selectMainAccount(state); + const net = selectNetwork(state); + const xpub = await account.getWatchIdentity(net); const nextChangeAddress = await xpub.getNextChangeAddress(); - await this.store.dispatchAsync(incrementChangeAddressIndex()); + await this.store.dispatchAsync(incrementChangeAddressIndex(account.getAccountID(), net)); return successMsg(nextChangeAddress); } @@ -163,17 +175,17 @@ export default class MarinaBroker extends Broker { case Marina.prototype.sendTransaction.name: { this.checkHostnameAuthorization(state); const [recipients, feeAssetHash] = params as [Recipient[], string | undefined]; - const lbtc = lbtcAssetByNetwork(state.app.network); + const lbtc = lbtcAssetByNetwork(selectNetwork(state)); const feeAsset = feeAssetHash ? feeAssetHash : lbtc; - if (![lbtc, ...state.taxi.taxiAssets].includes(feeAsset)) { + if (![lbtc, ...selectTaxiAssets(state)].includes(feeAsset)) { throw new Error(`${feeAsset} not supported as fee asset.`); } const { addressRecipients, data } = sortRecipients(recipients); await this.store.dispatchAsync( - setTxData(this.hostname, addressRecipients, feeAsset, state.app.network, data) + setTxData(this.hostname, addressRecipients, feeAsset, selectNetwork(state), data) ); const { accepted, signedTxHex } = await this.openAndWaitPopup( 'spend' @@ -214,13 +226,13 @@ export default class MarinaBroker extends Broker { case Marina.prototype.getTransactions.name: { this.checkHostnameAuthorization(state); - const transactions = walletTransactions(state); + const transactions = selectTransactions(MainAccountID)(state); return successMsg(transactions); } case Marina.prototype.getCoins.name: { this.checkHostnameAuthorization(state); - const coins = utxosSelector(state); + const coins = selectUtxos(MainAccountID)(state); const results: Utxo[] = coins.map((unblindedOutput) => ({ txid: unblindedOutput.txid, vout: unblindedOutput.vout, @@ -232,7 +244,7 @@ export default class MarinaBroker extends Broker { case Marina.prototype.getBalances.name: { this.checkHostnameAuthorization(state); - const balances = balancesSelector(state); + const balances = selectBalances(MainAccountID)(state); const assetGetter = assetGetterFromIAssets(state.assets); const balancesResult: Balance[] = []; for (const [assetHash, amount] of Object.entries(balances)) { @@ -243,7 +255,8 @@ export default class MarinaBroker extends Broker { case Marina.prototype.isReady.name: { try { - await getRestoredXPub(state); // check if Xpub is valid + const net = selectNetwork(state); + await selectMainAccount(state).getWatchIdentity(net); // check if Xpub is valid return successMsg(state.app.isOnboardingCompleted); } catch { // catch error = not ready @@ -253,8 +266,8 @@ export default class MarinaBroker extends Broker { case Marina.prototype.getFeeAssets.name: { this.checkHostnameAuthorization(state); - const lbtcAsset = lbtcAssetByNetwork(state.app.network); - return successMsg([lbtcAsset, ...state.taxi.taxiAssets]); + const lbtcAsset = lbtcAssetByNetwork(selectNetwork(state)); + return successMsg([lbtcAsset, ...selectTaxiAssets(state)]); } default: @@ -266,9 +279,3 @@ export default class MarinaBroker extends Broker { } }; } - -function getRestoredXPub(state: RootReducerState): Promise { - const xPubKey = masterPubKeySelector(state); - const opts = restorerOptsSelector(state); - return masterPubKeyRestorerFromState(xPubKey)(opts); -} diff --git a/src/content/store-cache.ts b/src/content/store-cache.ts index b0c84cf1..292dff94 100644 --- a/src/content/store-cache.ts +++ b/src/content/store-cache.ts @@ -6,6 +6,7 @@ import { compareUtxoState, networkChange, } from '../application/utils/marina-event'; +import { MainAccountID } from '../domain/account'; import { RootReducerState } from '../domain/common'; import { TxsHistory } from '../domain/transaction'; @@ -47,8 +48,9 @@ export function compareCacheForEvents( // create cache from State. export function newCacheFromState(state: RootReducerState): StoreCache { return { - utxoState: state.wallet.utxoMap, - txsHistoryState: state.txsHistory[state.app.network], + utxoState: state.wallet.unspentsAndTransactions[MainAccountID][state.app.network].utxosMap, + txsHistoryState: + state.wallet.unspentsAndTransactions[MainAccountID][state.app.network].transactions, enabledWebsitesState: state.connect.enabledSites, network: state.app.network, }; diff --git a/src/domain/account.ts b/src/domain/account.ts new file mode 100644 index 00000000..59e01725 --- /dev/null +++ b/src/domain/account.ts @@ -0,0 +1,81 @@ +import { + IdentityInterface, + MasterPublicKey, + Mnemonic, + StateRestorerOpts, + Restorer, + EsploraRestorerOpts, + masterPubKeyRestorerFromEsplora, + NetworkString, +} from 'ldk'; +import { decrypt } from '../application/utils/crypto'; +import { + newMasterPublicKey, + restoredMasterPublicKey, + restoredMnemonic, +} from '../application/utils/restorer'; +import { EncryptedMnemonic } from './encrypted-mnemonic'; +import { MasterBlindingKey } from './master-blinding-key'; +import { MasterXPub } from './master-extended-pub'; + +export const MainAccountID = 'mainAccount'; +export const RestrictedAssetAccountID = 'restrictedAssetAccount'; + +export type AccountID = typeof MainAccountID; + +/** + * Account domain represents the keys of the User + * + * - each Account is a derived of master private key (computed from mnemonic). + * - an Account returns two types of identities: a WatchOnly identity and a signing Identity. + * the watch-only identity is used to update utxos and transactions state + * the signing identity is used to sign inputs. it needs the user's password to decrypt the mnemonic. + */ +export interface Account< + SignID extends IdentityInterface = IdentityInterface, + WatchID extends IdentityInterface = IdentityInterface +> { + getAccountID(): AccountID; + getSigningIdentity(password: string, network: NetworkString): Promise; + getWatchIdentity(network: NetworkString): Promise; + getDeepRestorer(network: NetworkString): Restorer; +} + +// Main Account uses the default Mnemonic derivation path +// single-sig account used to send/receive regular assets +export type MnemonicAccount = Account; + +export interface MnemonicAccountData { + encryptedMnemonic: EncryptedMnemonic; + restorerOpts: Record; + masterXPub: MasterXPub; + masterBlindingKey: MasterBlindingKey; +} + +export function createMnemonicAccount(data: MnemonicAccountData): MnemonicAccount { + return { + getAccountID: () => MainAccountID, + getSigningIdentity: (password: string, network: NetworkString) => + restoredMnemonic( + decrypt(data.encryptedMnemonic, password), + data.restorerOpts[network], + network + ), + getWatchIdentity: (network: NetworkString) => + restoredMasterPublicKey( + data.masterXPub, + data.masterBlindingKey, + data.restorerOpts[network], + network + ), + getDeepRestorer: (network: NetworkString) => + masterPubKeyRestorerFromEsplora( + newMasterPublicKey(data.masterXPub, data.masterBlindingKey, network) + ), + }; +} + +export const initialRestorerOpts: StateRestorerOpts = { + lastUsedExternalIndex: -1, + lastUsedInternalIndex: -1, +}; diff --git a/src/domain/assets.ts b/src/domain/assets.ts index 3c1bc929..b08cd183 100644 --- a/src/domain/assets.ts +++ b/src/domain/assets.ts @@ -12,11 +12,22 @@ export type AssetGetter = (assetHash: string) => Asset & { assetHash: string }; export function assetGetterFromIAssets(assets: IAssets): AssetGetter { return (assetHash: string) => { const a = assets[assetHash]; + const defaultAssetObject = { + assetHash, + ticker: assetHash.slice(0, 4).toUpperCase(), + precision: defaultPrecision, + name: 'Unknown', + }; + + if (!a) { + return defaultAssetObject; + } + return { assetHash, - ticker: a ? a.ticker : assetHash.slice(0, 4).toUpperCase(), - precision: a ? a.precision : defaultPrecision, - name: a ? a.name : 'Unknown', + ticker: a ? a.ticker : defaultAssetObject.ticker, + precision: a ? a.precision : defaultAssetObject.precision, + name: a ? a.name : defaultAssetObject.name, }; }; } diff --git a/src/domain/common.ts b/src/domain/common.ts index 4866b5a1..a6de1fc3 100644 --- a/src/domain/common.ts +++ b/src/domain/common.ts @@ -1,19 +1,18 @@ import { ConnectData } from './connect'; -import { IWallet } from './wallet'; +import { WalletState } from './wallet'; import { IApp } from './app'; import { OnboardingState } from '../application/redux/reducers/onboarding-reducer'; import { TransactionState } from '../application/redux/reducers/transaction-reducer'; -import { TxsHistoryByNetwork } from './transaction'; import { TaxiState } from '../application/redux/reducers/taxi-reducer'; import { IAssets } from './assets'; +import { Action } from 'redux'; export interface RootReducerState { app: IApp; assets: IAssets; onboarding: OnboardingState; transaction: TransactionState; - txsHistory: TxsHistoryByNetwork; - wallet: IWallet; + wallet: WalletState; connect: ConnectData; taxi: TaxiState; } @@ -22,3 +21,5 @@ export interface IError { message: string; stack: string; } + +export type ActionWithPayload = Action & { payload: T }; diff --git a/src/domain/connect.ts b/src/domain/connect.ts index 522e3277..9655fbfc 100644 --- a/src/domain/connect.ts +++ b/src/domain/connect.ts @@ -1,6 +1,11 @@ import { NetworkString, RecipientInterface } from 'ldk'; import { DataRecipient } from 'marina-provider'; +export interface AssetAmount { + asset: string; + amount: number; +} + export type ConnectData = { enabledSites: Record; hostnameSelected: string; diff --git a/src/domain/message.ts b/src/domain/message.ts index 47e2658d..2a7af46c 100644 --- a/src/domain/message.ts +++ b/src/domain/message.ts @@ -4,6 +4,7 @@ export interface RequestMessage { id: string; name: string; params?: Array; + provider: string; } // the message received by the inject script diff --git a/src/domain/migrations.ts b/src/domain/migrations.ts new file mode 100644 index 00000000..e3ece222 --- /dev/null +++ b/src/domain/migrations.ts @@ -0,0 +1,43 @@ +import { StateRestorerOpts } from 'ldk'; +import { createMigrate } from 'redux-persist'; +import { PersistedState } from 'redux-persist/es/types'; +import { walletInitState } from '../application/redux/reducers/wallet-reducer'; +import { EncryptedMnemonic } from './encrypted-mnemonic'; +import { MasterBlindingKey } from './master-blinding-key'; +import { MasterXPub } from './master-extended-pub'; +import { WalletState } from './wallet'; + +// inspired by: https://gist.github.com/lafiosca/b7bbb569ae3fe5c1ce110bf71d7ee153 + +export type WalletPersistedStateV2 = WalletState & Partial; // the current version +type keysAddedInV2 = 'unspentsAndTransactions' | 'mainAccount' | 'updaterLoaders'; +type deletedInV2 = { + encryptedMnemonic: EncryptedMnemonic; + masterBlindingKey: MasterBlindingKey; + masterXPub: MasterXPub; + restorerOpts: StateRestorerOpts; +}; +export type WalletPersistedStateV1 = Omit & deletedInV2; + +export const walletMigrations = { + 2: (state: WalletPersistedStateV1): WalletPersistedStateV2 => { + return { + mainAccount: { + encryptedMnemonic: state.encryptedMnemonic, + masterBlindingKey: state.masterBlindingKey, + masterXPub: state.masterXPub, + restorerOpts: walletInitState.mainAccount.restorerOpts, + }, + deepRestorer: state.deepRestorer, + passwordHash: state.passwordHash, + unspentsAndTransactions: { + mainAccount: walletInitState.unspentsAndTransactions.mainAccount, + }, + updaterLoaders: 0, + isVerified: state.isVerified, + }; + }, +}; + +// `as any` is needed (redux-persist doesn't support generic types in createMigrate func) +export const walletMigrate = createMigrate(walletMigrations as any); diff --git a/src/domain/mnemonic.ts b/src/domain/mnemonic.ts index 1cc86a57..df2c8a18 100644 --- a/src/domain/mnemonic.ts +++ b/src/domain/mnemonic.ts @@ -1,4 +1,5 @@ import { validateMnemonic } from 'bip39'; +import { INVALID_MNEMONIC_ERROR } from '../application/utils/constants'; export type Mnemonic = string; @@ -6,6 +7,6 @@ export function createMnemonic(mnemo: string): Mnemonic { // Trim start-end and replace multiple spaces in between with a single space const mnemonic = mnemo.trim().replace(/ +(?= )/g, ''); - if (!validateMnemonic(mnemonic)) throw new Error('Invalid mnemonic'); + if (!validateMnemonic(mnemonic)) throw new Error(INVALID_MNEMONIC_ERROR); return mnemonic; } diff --git a/src/domain/password-hash.ts b/src/domain/password-hash.ts index 067bbacb..0e199ea9 100644 --- a/src/domain/password-hash.ts +++ b/src/domain/password-hash.ts @@ -1,4 +1,4 @@ -import { hashPassword } from '../application/utils'; +import { hashPassword } from '../application/utils/crypto'; import { Password } from './password'; export type PasswordHash = string; diff --git a/src/domain/transaction.ts b/src/domain/transaction.ts index f4e178be..dfa71ec6 100644 --- a/src/domain/transaction.ts +++ b/src/domain/transaction.ts @@ -1,11 +1,14 @@ -import { address, decodePset, NetworkString } from 'ldk'; -import { Address } from './address'; -import { IError } from './common'; +import { NetworkString, UnblindedOutput } from 'ldk'; -export type TxsHistory = Record; +export type UtxosAndTxsByNetwork = Record; + +export interface UtxosAndTxs { + // outpoint string -> UnblindedOutput + utxosMap: Record; + transactions: TxsHistory; +} -export type TxsHistoryByNetwork = Record & - Partial>; +export type TxsHistory = Record; export enum TxType { SelfTransfer = 0, @@ -36,80 +39,19 @@ export interface TxDisplayInterface { blockTimeMs?: number; } -export interface TxsByAssetsInterface { - [asset: string]: Array; -} - -export interface TxsByTxIdInterface { - [txid: string]: TxDisplayInterface; -} - -export interface OutputBlinders { - asset: string; - value: number; - assetBlinder: string; - valueBlinder: string; -} - -export interface Transaction { - pset: string; - sendAddress: string; - sendAsset: string; - sendAmount: number; - feeAsset: string; - feeAmount: number; - changeAddress?: Address; -} - -export interface TransactionDTO { - value: string; - sendAddress: string; - sendAsset: string; - sendAmount: number; - feeAsset: string; - feeAmount: number; - changeAddress?: [address: string, derivationPath?: string]; -} - -function isValidTx(tx: string): boolean { - try { - decodePset(tx); - return true; - } catch (ignore) { - return false; - } -} - -function isValidAddress(addr: string): boolean { - try { - address.toOutputScript(addr); - return true; - } catch (ignore) { - return false; - } -} - -function isValidAsset(asset: string): boolean { - return asset.length === 64; -} - -function isValidAmount(amount: number): boolean { - return amount > 0 && amount <= 2100000000000000; -} - -export function createTransaction(props: Transaction): Transaction { - if ( - !isValidTx(props.pset) || - !isValidAddress(props.sendAddress) || - !isValidAsset(props.sendAsset) || - !isValidAmount(props.sendAmount) || - !isValidAsset(props.feeAsset) || - !isValidAmount(props.feeAmount) - ) { - throw new Error('Transaction must be a valid base64 encoded PSET'); - } else if (props.changeAddress && !isValidAddress(props.changeAddress.value)) { - throw new Error('Transaction must be a valid base64 encoded PSET'); - } else { - return props; - } +export function newEmptyUtxosAndTxsHistory(): UtxosAndTxsByNetwork { + return { + liquid: { + utxosMap: {}, + transactions: {}, + }, + testnet: { + utxosMap: {}, + transactions: {}, + }, + regtest: { + utxosMap: {}, + transactions: {}, + }, + }; } diff --git a/src/domain/wallet.ts b/src/domain/wallet.ts index 3173ef75..fa14510e 100644 --- a/src/domain/wallet.ts +++ b/src/domain/wallet.ts @@ -1,26 +1,16 @@ -import { UnblindedOutput, StateRestorerOpts } from 'ldk'; -import { IError } from './common'; -import { EncryptedMnemonic } from './encrypted-mnemonic'; -import { MasterBlindingKey } from './master-blinding-key'; -import { MasterXPub } from './master-extended-pub'; +import { AccountID, MainAccountID, MnemonicAccountData } from './account'; import { PasswordHash } from './password-hash'; +import { UtxosAndTxsByNetwork } from './transaction'; -export interface IWallet { - encryptedMnemonic: EncryptedMnemonic; - errors?: Record; - masterXPub: MasterXPub; - masterBlindingKey: MasterBlindingKey; +export interface WalletState { + [MainAccountID]: MnemonicAccountData; + unspentsAndTransactions: Record; passwordHash: PasswordHash; - utxoMap: Record; - restorerOpts: StateRestorerOpts; deepRestorer: { gapLimit: number; isLoading: boolean; error?: string; }; - updaterLoaders: { - utxos: boolean; - txs: boolean; - }; + updaterLoaders: number; isVerified: boolean; } diff --git a/src/inject/inject-script.ts b/src/inject/inject-script.ts index 0940acf6..e25bb862 100644 --- a/src/inject/inject-script.ts +++ b/src/inject/inject-script.ts @@ -1,7 +1,7 @@ // this is an inject script, "injected" in web pages // this script set up window.marina provider -import Marina from './marina'; +import Marina from './marina/provider'; const marina = new Marina(); (window as Record)[Marina.PROVIDER_NAME] = marina; diff --git a/src/inject/marinaEventHandler.ts b/src/inject/marina/marinaEventHandler.ts similarity index 94% rename from src/inject/marinaEventHandler.ts rename to src/inject/marina/marinaEventHandler.ts index 24b1f59a..6b86176f 100644 --- a/src/inject/marinaEventHandler.ts +++ b/src/inject/marina/marinaEventHandler.ts @@ -1,6 +1,6 @@ import { MarinaEventType } from 'marina-provider'; -import { parse } from '../application/utils/browser-storage-converters'; -import { makeid } from './proxy'; +import { parse } from '../../application/utils/browser-storage-converters'; +import { makeid } from '../proxy'; type EventListenerID = string; diff --git a/src/inject/marina.ts b/src/inject/marina/provider.ts similarity index 97% rename from src/inject/marina.ts rename to src/inject/marina/provider.ts index 4925104a..7a177bc5 100644 --- a/src/inject/marina.ts +++ b/src/inject/marina/provider.ts @@ -11,7 +11,7 @@ import { TransactionID, } from 'marina-provider'; import MarinaEventHandler from './marinaEventHandler'; -import WindowProxy from './proxy'; +import WindowProxy from '../proxy'; export default class Marina extends WindowProxy implements MarinaProvider { static PROVIDER_NAME = 'marina'; @@ -19,7 +19,7 @@ export default class Marina extends WindowProxy implements MarinaProvider { private eventHandler: MarinaEventHandler; constructor() { - super(); + super(Marina.PROVIDER_NAME); this.eventHandler = new MarinaEventHandler(); } diff --git a/src/inject/proxy.ts b/src/inject/proxy.ts index 42556f3e..c2d39d61 100644 --- a/src/inject/proxy.ts +++ b/src/inject/proxy.ts @@ -1,6 +1,12 @@ import { parse } from '../application/utils/browser-storage-converters'; export default class WindowProxy { + protected providerName: string; + + constructor(providerName: string) { + this.providerName = providerName; + } + proxy(name: string, params: any[] = []): Promise { return new Promise((resolve, reject) => { const id = makeid(16); @@ -32,6 +38,7 @@ export default class WindowProxy { id, name, params, + provider: this.providerName, }, window.location.origin ); diff --git a/src/presentation/components/address-amount-form.tsx b/src/presentation/components/address-amount-form.tsx index 1da85311..46b68f98 100644 --- a/src/presentation/components/address-amount-form.tsx +++ b/src/presentation/components/address-amount-form.tsx @@ -1,46 +1,59 @@ import { FormikProps, withFormik } from 'formik'; -import { - masterPubKeyRestorerFromState, - MasterPublicKey, - NetworkString, - StateRestorerOpts, -} from 'ldk'; import { RouteComponentProps } from 'react-router'; import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; import cx from 'classnames'; import Button from './button'; -import { setAddressesAndAmount } from '../../application/redux/actions/transaction'; +import { + setAddressesAndAmount, + setPendingTxStep, +} from '../../application/redux/actions/transaction'; import { createAddress } from '../../domain/address'; -import { fromSatoshi, getMinAmountFromPrecision, toSatoshi } from '../utils'; import { SEND_CHOOSE_FEE_ROUTE } from '../routes/constants'; -import { defaultPrecision, isValidAddressForNetwork } from '../../application/utils'; import * as Yup from 'yup'; import { TransactionState } from '../../application/redux/reducers/transaction-reducer'; -import { IAssets } from '../../domain/assets'; +import { Asset } from '../../domain/assets'; import { incrementChangeAddressIndex } from '../../application/redux/actions/wallet'; +import { Account } from '../../domain/account'; +import { NetworkString } from 'ldk'; +import { isValidAddressForNetwork } from '../../application/utils/address'; +import { fromSatoshi, getMinAmountFromPrecision, toSatoshi } from '../utils'; interface AddressAmountFormValues { address: string; amount: number; assetTicker: string; assetPrecision: number; - balances: { [assetHash: string]: number }; + balance: number; } interface AddressAmountFormProps { - balances: { [assetHash: string]: number }; + balance: number; dispatch: ProxyStoreDispatch; - assetPrecision: number; + asset: Asset; history: RouteComponentProps['history']; - pubKey: MasterPublicKey; - restorerOpts: StateRestorerOpts; transaction: TransactionState; - assets: IAssets; network: NetworkString; + account: Account; } const AddressAmountForm = (props: FormikProps) => { - const { errors, handleChange, handleBlur, handleSubmit, isSubmitting, touched, values } = props; + const { + errors, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + touched, + values, + setFieldValue, + setFieldTouched, + } = props; + + const setMaxAmount = () => { + const maxAmount = values.balance; + setFieldValue('amount', maxAmount); + setFieldTouched('amount', true, false); + }; return (
@@ -91,6 +104,7 @@ const AddressAmountForm = (props: FormikProps) => { onBlur={handleBlur} placeholder="0" type="number" + lang="en" value={values.amount} /> @@ -98,8 +112,17 @@ const AddressAmountForm = (props: FormikProps) => { +

+ +

{errors.amount && touched.amount && ( -

{errors.amount}

+

{errors.amount}

)} @@ -126,14 +149,11 @@ const AddressAmountEnhancedForm = withFormik 0 - ? fromSatoshi( - props.transaction.sendAmount, - props.assets[props.transaction.sendAsset].precision - ) + ? fromSatoshi(props.transaction.sendAmount ?? 0, props.asset.precision) : ('' as unknown as number), - assetTicker: props.assets[props.transaction.sendAsset]?.ticker ?? '', - assetPrecision: props.assets[props.transaction.sendAsset]?.precision ?? defaultPrecision, - balances: props.balances, + assetTicker: props.asset.ticker ?? '??', + assetPrecision: props.asset.precision, + balance: fromSatoshi(props.balance ?? 0, props.asset.precision), }), validationSchema: (props: AddressAmountFormProps): any => @@ -148,35 +168,36 @@ const AddressAmountEnhancedForm = withFormik { - return ( - value !== undefined && - value <= fromSatoshi(props.balances[props.transaction.sendAsset], props.assetPrecision) - ); + return value !== undefined && value <= fromSatoshi(props.balance, props.asset.precision); }), }), handleSubmit: async (values, { props }) => { - const masterPubKey = await masterPubKeyRestorerFromState(props.pubKey)(props.restorerOpts); + const masterPubKey = await props.account.getWatchIdentity(props.network); const changeAddressGenerated = await masterPubKey.getNextChangeAddress(); const changeAddress = createAddress( changeAddressGenerated.confidentialAddress, changeAddressGenerated.derivationPath ); - await props.dispatch(incrementChangeAddressIndex()); // persist address in wallet + await props.dispatch(incrementChangeAddressIndex(props.account.getAccountID(), props.network)); // persist address in wallet await props .dispatch( setAddressesAndAmount( - createAddress(values.address), - changeAddress, - toSatoshi(values.amount, values.assetPrecision) + toSatoshi(values.amount, values.assetPrecision), + [changeAddress], + createAddress(values.address) ) ) .catch(console.error); + await props.dispatch(setPendingTxStep('address-amount')); props.history.push({ pathname: SEND_CHOOSE_FEE_ROUTE, }); diff --git a/src/presentation/components/asset-list-screen.tsx b/src/presentation/components/asset-list-screen.tsx index 042eedbb..8b41810c 100644 --- a/src/presentation/components/asset-list-screen.tsx +++ b/src/presentation/components/asset-list-screen.tsx @@ -4,28 +4,20 @@ import { DEFAULT_ROUTE } from '../routes/constants'; import ButtonAsset from './button-asset'; import InputIcon from './input-icon'; import ShellPopUp from './shell-popup'; -import { getAssetImage } from '../../application/utils'; +import { getAssetImage } from '../../application/utils/constants'; import { BalancesByAsset } from '../../application/redux/selectors/balance.selector'; import { Asset } from '../../domain/assets'; import ButtonList from './button-list'; -import { NetworkString } from 'ldk'; import { sortAssets } from '../utils/sort'; export interface AssetListProps { - network: NetworkString; assets: Array; // the assets to display onClick: (assetHash: string) => Promise; balances?: BalancesByAsset; title: string; } -const AssetListScreen: React.FC = ({ - title, - onClick, - network, - assets, - balances, -}) => { +const AssetListScreen: React.FC = ({ title, onClick, assets, balances }) => { const history = useHistory(); // sort assets @@ -51,6 +43,10 @@ const AssetListScreen: React.FC = ({ ); setSearchResults(results); + return () => { + setSearchTerm(''); + setSearchResults(assets); + }; }, [searchTerm]); const handleChange = (event: React.ChangeEvent) => { diff --git a/src/presentation/components/buttons-send-receive.tsx b/src/presentation/components/buttons-send-receive.tsx index 508b7b07..b2ac0f16 100644 --- a/src/presentation/components/buttons-send-receive.tsx +++ b/src/presentation/components/buttons-send-receive.tsx @@ -1,15 +1,41 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { connect } from 'react-redux'; +import browser from 'webextension-polyfill'; +import { RootReducerState } from '../../domain/common'; +import { BACKUP_UNLOCK_ROUTE } from '../routes/constants'; import Button from './button'; +import SaveMnemonicModal from './modal-save-mnemonic'; -interface Props { +interface ConnectedProps { + isWalletVerified: boolean; +} + +type Props = ConnectedProps & { onReceive: () => void; onSend: () => void; -} +}; + +const ButtonsSendReceive: React.FC = ({ isWalletVerified, onReceive, onSend }) => { + const [isSaveMnemonicModalOpen, showSaveMnemonicModal] = useState(false); + + const handleSaveMnemonicConfirm = async () => { + await browser.tabs.create({ url: `home.html#${BACKUP_UNLOCK_ROUTE}` }); + }; + + const onReceiveWithVerifiedCheck = () => { + if (!isWalletVerified) { + showSaveMnemonicModal(true); + } else { + onReceive(); + } + }; -const ButtonsSendReceive: React.FC = ({ onReceive, onSend }) => { return (
- @@ -17,8 +43,18 @@ const ButtonsSendReceive: React.FC = ({ onReceive, onSend }) => { send Send + + showSaveMnemonicModal(false)} + handleConfirm={handleSaveMnemonicConfirm} + />
); }; -export default ButtonsSendReceive; +const mapStateToProps = (state: RootReducerState): ConnectedProps => ({ + isWalletVerified: state.wallet.isVerified, +}); + +export default connect(mapStateToProps)(ButtonsSendReceive); diff --git a/src/presentation/components/shell-popup.tsx b/src/presentation/components/shell-popup.tsx index e2acc046..e7b808bf 100644 --- a/src/presentation/components/shell-popup.tsx +++ b/src/presentation/components/shell-popup.tsx @@ -4,11 +4,16 @@ import ModalMenu from './modal-menu'; import { DEFAULT_ROUTE } from '../routes/constants'; import { useDispatch, useSelector } from 'react-redux'; import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; -import { updateUtxos } from '../../application/redux/actions/utxos'; -import { flushPendingTx, updateTxs } from '../../application/redux/actions/transaction'; -import { RootReducerState } from '../../domain/common'; -import { selectUpdaterLoaders } from '../../application/redux/selectors/wallet.selector'; +import { flushPendingTx } from '../../application/redux/actions/transaction'; +import { + selectAllAccountsIDs, + selectDeepRestorerIsLoading, + selectUpdaterIsLoading, +} from '../../application/redux/selectors/wallet.selector'; +import { updateTaskAction } from '../../application/redux/actions/updater'; import { formatNetwork } from '../utils'; +import { selectNetwork } from '../../application/redux/selectors/app.selector'; +import { AccountID } from '../../domain/account'; interface Props { btnDisabled?: boolean; @@ -33,12 +38,10 @@ const ShellPopUp: React.FC = ({ const history = useHistory(); const dispatch = useDispatch(); - const network = useSelector((state: RootReducerState) => state.app.network); - const updaterLoaders = useSelector(selectUpdaterLoaders); - - const deepRestorerLoading = useSelector( - (state: RootReducerState) => state.wallet.deepRestorer.isLoading - ); + const allAccountsIds = useSelector(selectAllAccountsIDs); + const updaterIsLoading = useSelector(selectUpdaterIsLoading); + const deepRestorerLoading = useSelector(selectDeepRestorerIsLoading); + const network = useSelector(selectNetwork); // Menu modal const [isMenuModalOpen, showMenuModal] = useState(false); const openMenuModal = () => showMenuModal(true); @@ -47,11 +50,12 @@ const ShellPopUp: React.FC = ({ const goToHome = async () => { // If already home, refresh state and return balances if (history.location.pathname === '/') { - dispatch(updateUtxos()).catch(console.error); - dispatch(updateTxs()).catch(console.error); + const makeUpdateTaskForId = (id: AccountID) => updateTaskAction(id, network); + await Promise.all(allAccountsIds.map(makeUpdateTaskForId).map(dispatch)); + } else { + history.push(DEFAULT_ROUTE); } await dispatch(flushPendingTx()); - history.push(DEFAULT_ROUTE); }; const handleBackBtn = () => { if (backBtnCb) { @@ -112,7 +116,7 @@ const ShellPopUp: React.FC = ({ )} - {(deepRestorerLoading || updaterLoaders.utxos || updaterLoaders.txs) && loader()} + {(deepRestorerLoading || updaterIsLoading) && loader()} @@ -139,7 +143,7 @@ const ShellPopUp: React.FC = ({ function getLoaderText(): string | undefined { if (deepRestorerLoading) return 'Restoring...'; - if (updaterLoaders.txs || updaterLoaders.utxos) return 'Updating...'; + if (updaterIsLoading) return 'Updating...'; return undefined; } diff --git a/src/presentation/connect/popupBroker.ts b/src/presentation/connect/popupBroker.ts index 8f6efd52..a5625bfb 100644 --- a/src/presentation/connect/popupBroker.ts +++ b/src/presentation/connect/popupBroker.ts @@ -5,12 +5,13 @@ import { newSuccessResponseMessage, RequestMessage, } from '../../domain/message'; +import PopupWindowProxy from './popupWindowProxy'; export const POPUP_RESPONSE = 'POPUP_RESPONSE'; export default class PopupBroker extends Broker { static Start() { - const broker = new PopupBroker(); + const broker = new PopupBroker(PopupWindowProxy.PROVIDER_NAME); broker.start(); } diff --git a/src/presentation/connect/popupWindowProxy.ts b/src/presentation/connect/popupWindowProxy.ts index 56166a35..08f57ad4 100644 --- a/src/presentation/connect/popupWindowProxy.ts +++ b/src/presentation/connect/popupWindowProxy.ts @@ -3,6 +3,12 @@ import WindowProxy from '../../inject/proxy'; import { POPUP_RESPONSE } from './popupBroker'; export default class PopupWindowProxy extends WindowProxy { + static PROVIDER_NAME = 'connect'; + + constructor() { + super(PopupWindowProxy.PROVIDER_NAME); + } + sendResponse(message: PopupResponseMessage): Promise { return this.proxy(POPUP_RESPONSE, [message]); } diff --git a/src/presentation/connect/sign-msg.tsx b/src/presentation/connect/sign-msg.tsx index 05434a51..90019331 100644 --- a/src/presentation/connect/sign-msg.tsx +++ b/src/presentation/connect/sign-msg.tsx @@ -7,7 +7,6 @@ import { connectWithConnectData, WithConnectDataProps, } from '../../application/redux/containers/with-connect-data.container'; -import { decrypt } from '../../application/utils'; import { signMessageWithMnemonic } from '../../application/utils/message'; import { networks } from 'liquidjs-lib'; import { useSelector } from 'react-redux'; @@ -15,7 +14,11 @@ import { RootReducerState } from '../../domain/common'; import PopupWindowProxy from './popupWindowProxy'; import { SignedMessage } from 'marina-provider'; import { NetworkString } from 'ldk'; -import { SOMETHING_WENT_WRONG_ERROR } from '../../application/utils/constants'; +import { + INVALID_PASSWORD_ERROR, + SOMETHING_WENT_WRONG_ERROR, +} from '../../application/utils/constants'; +import { decrypt } from '../../application/utils/crypto'; function signMsgWithPassword( message: string, @@ -27,7 +30,7 @@ function signMsgWithPassword( const mnemonic = decrypt(encryptedMnemonic, password); return signMessageWithMnemonic(message, mnemonic, networks[network]); } catch (e: any) { - throw new Error('Invalid password'); + throw new Error(INVALID_PASSWORD_ERROR); } } @@ -41,7 +44,7 @@ const ConnectSignMsg: React.FC = ({ connectData }) => { const [error, setError] = useState(''); const network = useSelector((state: RootReducerState) => state.app.network); const encryptedMnemonic = useSelector( - (state: RootReducerState) => state.wallet.encryptedMnemonic + (state: RootReducerState) => state.wallet.mainAccount.encryptedMnemonic ); const popupWindowProxy = new PopupWindowProxy(); diff --git a/src/presentation/connect/sign-pset.tsx b/src/presentation/connect/sign-pset.tsx index 388cfc90..53daabcf 100644 --- a/src/presentation/connect/sign-pset.tsx +++ b/src/presentation/connect/sign-pset.tsx @@ -8,11 +8,11 @@ import { WithConnectDataProps, } from '../../application/redux/containers/with-connect-data.container'; import { useSelector } from 'react-redux'; -import { restorerOptsSelector } from '../../application/redux/selectors/wallet.selector'; -import { RootReducerState } from '../../domain/common'; -import { decrypt, mnemonicWallet } from '../../application/utils'; +import { selectAllAccounts } from '../../application/redux/selectors/wallet.selector'; import PopupWindowProxy from './popupWindowProxy'; +import { signPset } from '../../application/utils/transaction'; import { SOMETHING_WENT_WRONG_ERROR } from '../../application/utils/constants'; +import { selectNetwork } from '../../application/redux/selectors/app.selector'; export interface SignTransactionPopupResponse { accepted: boolean; @@ -25,11 +25,8 @@ const ConnectSignTransaction: React.FC = ({ connectData }) const [isModalUnlockOpen, showUnlockModal] = useState(false); const [error, setError] = useState(''); - const network = useSelector((state: RootReducerState) => state.app.network); - const restorerOpts = useSelector(restorerOptsSelector); - const encryptedMnemonic = useSelector( - (state: RootReducerState) => state.wallet.encryptedMnemonic - ); + const accounts = useSelector(selectAllAccounts); + const network = useSelector(selectNetwork); const handleModalUnlockClose = () => showUnlockModal(false); const handleUnlockModalOpen = () => showUnlockModal(true); @@ -53,12 +50,11 @@ const ConnectSignTransaction: React.FC = ({ connectData }) const { tx } = connectData; if (!tx || !tx.pset) throw new Error('No transaction to sign'); - const mnemo = await mnemonicWallet( - decrypt(encryptedMnemonic, password), - restorerOpts, - network + const identities = await Promise.all( + accounts.map((a) => a.getSigningIdentity(password, network)) ); - const signedPset = await mnemo.signPset(tx.pset); + const signedPset = await signPset(tx.pset, identities); + await sendResponseMessage(true, signedPset); window.close(); diff --git a/src/presentation/connect/spend.tsx b/src/presentation/connect/spend.tsx index ef0a6b56..bb5003b1 100644 --- a/src/presentation/connect/spend.tsx +++ b/src/presentation/connect/spend.tsx @@ -12,7 +12,8 @@ import { import { RootReducerState } from '../../domain/common'; import type { AddressInterface, - Mnemonic, + ChangeAddressFromAssetGetter, + IdentityInterface, NetworkString, RecipientInterface, UnblindedOutput, @@ -20,16 +21,14 @@ import type { import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; import { flushTx } from '../../application/redux/actions/connect'; import { ConnectData } from '../../domain/connect'; -import { mnemonicWallet } from '../../application/utils/restorer'; import { blindAndSignPset, createSendPset } from '../../application/utils/transaction'; import { incrementChangeAddressIndex } from '../../application/redux/actions/wallet'; -import { - restorerOptsSelector, - utxosSelector, -} from '../../application/redux/selectors/wallet.selector'; -import { decrypt } from '../../application/utils/crypto'; +import { selectMainAccount, selectUtxos } from '../../application/redux/selectors/wallet.selector'; import PopupWindowProxy from './popupWindowProxy'; +import { Account, MainAccountID } from '../../domain/account'; import { SOMETHING_WENT_WRONG_ERROR } from '../../application/utils/constants'; +import { selectNetwork } from '../../application/redux/selectors/app.selector'; +import { lbtcAssetByNetwork } from '../../application/utils/network'; export interface SpendPopupResponse { accepted: boolean; @@ -38,12 +37,10 @@ export interface SpendPopupResponse { const ConnectSpend: React.FC = ({ connectData }) => { const assets = useSelector((state: RootReducerState) => state.assets); - const coins = useSelector(utxosSelector); - const restorerOpts = useSelector(restorerOptsSelector); - const encryptedMnemonic = useSelector( - (state: RootReducerState) => state.wallet.encryptedMnemonic - ); - const network = useSelector((state: RootReducerState) => state.app.network); + const mainAccount = useSelector(selectMainAccount); + + const network = useSelector(selectNetwork); + const coins = useSelector(selectUtxos(MainAccountID)); const dispatch = useDispatch(); @@ -74,19 +71,32 @@ const ConnectSpend: React.FC = ({ connectData }) => { const handleUnlock = async (password: string) => { if (!password || password.length === 0) return; + if (!connectData.tx?.recipients) return; try { - const mnemonicIdentity = await mnemonicWallet( - decrypt(encryptedMnemonic, password), - restorerOpts, + const assets = assetsSet( + connectData.tx?.recipients, + connectData.tx.feeAssetHash ?? lbtcAssetByNetwork(network) + ); + + const { getter, changeAddresses } = await changeAddressGetter( + mainAccount, + assets, + dispatch, network ); + + const accounts: Account[] = [mainAccount]; + const identities = await Promise.all( + accounts.map((a) => a.getSigningIdentity(password, network)) + ); const signedTxHex = await makeTransaction( - mnemonicIdentity, + identities, coins, connectData.tx, network, - dispatch + getter, + changeAddresses ); await sendResponseMessage(true, signedTxHex); @@ -166,36 +176,50 @@ const ConnectSpend: React.FC = ({ connectData }) => { export default connectWithConnectData(ConnectSpend); -async function makeTransaction( - mnemonic: Mnemonic, - coins: UnblindedOutput[], - connectDataTx: ConnectData['tx'], - network: NetworkString, - dispatch: ProxyStoreDispatch -) { - if (!connectDataTx || !connectDataTx.recipients || !connectDataTx.feeAssetHash) - throw new Error('transaction data are missing'); - - const { recipients, feeAssetHash, data } = connectDataTx; - - const assets = Array.from(new Set(recipients.map(({ asset }) => asset).concat(feeAssetHash))); +function assetsSet(recipients: RecipientInterface[], feeAsset: string): Set { + return new Set(recipients.map((r) => r.asset).concat([feeAsset])); +} +async function changeAddressGetter( + account: Account, + assets: Set, + dispatch: ProxyStoreDispatch, + net: NetworkString +): Promise<{ getter: ChangeAddressFromAssetGetter; changeAddresses: string[] }> { const changeAddresses: Record = {}; const persisted: Record = {}; + const id = await account.getWatchIdentity(net); for (const asset of assets) { - changeAddresses[asset] = await mnemonic.getNextChangeAddress(); + changeAddresses[asset] = await id.getNextChangeAddress(); persisted[asset] = false; } - const changeAddressGetter = (asset: string) => { - if (!assets.includes(asset)) return ''; // will throw an error in coin selector - if (!persisted[asset]) { - dispatch(incrementChangeAddressIndex()).catch(console.error); - persisted[asset] = true; - } - return changeAddresses[asset].confidentialAddress; + return { + getter: (asset: string) => { + if (!assets.has(asset)) throw new Error('missing change address'); + if (!persisted[asset]) { + dispatch(incrementChangeAddressIndex(account.getAccountID(), net)).catch(console.error); + persisted[asset] = true; + } + return changeAddresses[asset].confidentialAddress; + }, + changeAddresses: Object.values(changeAddresses).map((a) => a.confidentialAddress), }; +} + +async function makeTransaction( + identities: IdentityInterface[], + coins: UnblindedOutput[], + connectDataTx: ConnectData['tx'], + network: NetworkString, + changeAddressGetter: ChangeAddressFromAssetGetter, + changeAddresses: string[] +) { + if (!connectDataTx || !connectDataTx.recipients || !connectDataTx.feeAssetHash) + throw new Error('transaction data are missing'); + + const { recipients, feeAssetHash, data } = connectDataTx; const unsignedPset = await createSendPset( recipients, @@ -207,11 +231,11 @@ async function makeTransaction( ); const txHex = await blindAndSignPset( - mnemonic, unsignedPset, - recipients - .map(({ address }) => address) - .concat(Object.values(changeAddresses).map(({ confidentialAddress }) => confidentialAddress)) + coins, + identities, + recipients.map(({ address }) => address), + changeAddresses ); return txHex; diff --git a/src/presentation/onboarding/backup-unlock/index.tsx b/src/presentation/onboarding/backup-unlock/index.tsx index d0d94b82..4e5d7d80 100644 --- a/src/presentation/onboarding/backup-unlock/index.tsx +++ b/src/presentation/onboarding/backup-unlock/index.tsx @@ -5,7 +5,7 @@ import * as Yup from 'yup'; import Shell from '../../components/shell'; import Input from '../../components/input'; import Button from '../../components/button'; -import { decrypt } from '../../../application/utils'; +import { decrypt } from '../../../application/utils/crypto'; import { INITIALIZE_SEED_PHRASE_ROUTE } from '../../routes/constants'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; import { EncryptedMnemonic } from '../../../domain/encrypted-mnemonic'; @@ -69,7 +69,9 @@ const BackUpUnlockEnhancedForm = withFormik { const history = useHistory(); const dispatch = useDispatch(); - const encryptedMnemonic = useSelector((s: RootReducerState) => s.wallet.encryptedMnemonic); + const encryptedMnemonic = useSelector( + (s: RootReducerState) => s.wallet.mainAccount.encryptedMnemonic + ); return ( diff --git a/src/presentation/onboarding/end-of-flow/index.tsx b/src/presentation/onboarding/end-of-flow/index.tsx index 4a400a00..2eaa1045 100644 --- a/src/presentation/onboarding/end-of-flow/index.tsx +++ b/src/presentation/onboarding/end-of-flow/index.tsx @@ -1,14 +1,20 @@ -import { NetworkString } from 'ldk'; +import { IdentityType, Mnemonic, mnemonicRestorerFromEsplora, NetworkString } from 'ldk'; import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { onboardingCompleted, reset } from '../../../application/redux/actions/app'; import { flushOnboarding } from '../../../application/redux/actions/onboarding'; -import { setWalletData } from '../../../application/redux/actions/wallet'; +import { setVerified, setWalletData } from '../../../application/redux/actions/wallet'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; +import { walletInitState } from '../../../application/redux/reducers/wallet-reducer'; +import { encrypt, hashPassword } from '../../../application/utils/crypto'; import { setUpPopup } from '../../../application/utils/popup'; -import { createWalletFromMnemonic } from '../../../application/utils/wallet'; +import { getStateRestorerOptsFromAddresses } from '../../../application/utils/restorer'; +import { MnemonicAccountData } from '../../../domain/account'; +import { createMasterBlindingKey } from '../../../domain/master-blinding-key'; +import { createMasterXPub } from '../../../domain/master-extended-pub'; import { createMnemonic } from '../../../domain/mnemonic'; -import { createPassword } from '../../../domain/password'; +import { createPassword, Password } from '../../../domain/password'; +import { PasswordHash } from '../../../domain/password-hash'; import Button from '../../components/button'; import MermaidLoader from '../../components/mermaid-loader'; import Shell from '../../components/shell'; @@ -21,6 +27,7 @@ export interface EndOfFlowProps { network: NetworkString; explorerURL: string; hasMnemonicRegistered: boolean; + walletVerified: boolean; } const EndOfFlowOnboardingView: React.FC = ({ @@ -30,6 +37,7 @@ const EndOfFlowOnboardingView: React.FC = ({ network, explorerURL, hasMnemonicRegistered, + walletVerified, }) => { const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(true); @@ -39,8 +47,9 @@ const EndOfFlowOnboardingView: React.FC = ({ try { setIsLoading(true); setErrorMsg(undefined); + if (!isFromPopupFlow) { - const walletData = await createWalletFromMnemonic( + const { accountData, passwordHash } = await createWalletFromMnemonic( createPassword(password), createMnemonic(mnemonic), network, @@ -50,12 +59,17 @@ const EndOfFlowOnboardingView: React.FC = ({ if (hasMnemonicRegistered) { await dispatch(reset()); } - await dispatch(setWalletData(walletData)); - // Startup alarms to fetch utxos & set the popup page + await dispatch(setWalletData(accountData, passwordHash)); + // set the popup await setUpPopup(); await dispatch(onboardingCompleted()); } + + if (walletVerified) { + // the user has confirmed via seed-confirm page + await dispatch(setVerified()); + } await dispatch(flushOnboarding()); } catch (err: unknown) { console.error(err); @@ -98,4 +112,42 @@ const EndOfFlowOnboardingView: React.FC = ({ ); }; +export async function createWalletFromMnemonic( + password: Password, + mnemonic: string, + chain: NetworkString, + esploraURL: string +): Promise<{ accountData: MnemonicAccountData; passwordHash: PasswordHash }> { + const toRestore = new Mnemonic({ + chain, + type: IdentityType.Mnemonic, + opts: { mnemonic }, + }); + + const mnemonicIdentity = await mnemonicRestorerFromEsplora(toRestore)({ + esploraURL, + gapLimit: 20, + }); + const masterXPub = createMasterXPub(mnemonicIdentity.masterPublicKey); + const masterBlindingKey = createMasterBlindingKey(mnemonicIdentity.masterBlindingKey); + const encryptedMnemonic = encrypt(mnemonic, password); + const passwordHash = hashPassword(password); + const addresses = await mnemonicIdentity.getAddresses(); + + const accountData = { + restorerOpts: { + ...walletInitState.mainAccount.restorerOpts, + [chain]: getStateRestorerOptsFromAddresses(addresses), + }, + encryptedMnemonic, + masterXPub, + masterBlindingKey, + }; + + return { + accountData, + passwordHash, + }; +} + export default EndOfFlowOnboardingView; diff --git a/src/presentation/onboarding/onboarding-form.tsx b/src/presentation/onboarding/onboarding-form.tsx new file mode 100644 index 00000000..e243d357 --- /dev/null +++ b/src/presentation/onboarding/onboarding-form.tsx @@ -0,0 +1,147 @@ +import { useHistory } from 'react-router-dom'; +import { FormikProps, withFormik } from 'formik'; +import * as Yup from 'yup'; +import cx from 'classnames'; +import React from 'react'; +import Button from '../components/button'; +import { SETTINGS_TERMS_ROUTE } from '../routes/constants'; + +const OpenTerms: React.FC = () => { + const history = useHistory(); + + const handleClick = (e: any) => { + e.preventDefault(); + + history.push({ + pathname: SETTINGS_TERMS_ROUTE, + state: { isFullScreen: true }, + }); + }; + + return ( + /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ + + terms of service + + ); +}; + +interface OnboardingFormValues { + password: string; + confirmPassword: string; + makeSecurityAccount: boolean; + acceptTerms: boolean; +} + +const OnboardingFormView = (props: FormikProps) => { + const { values, touched, errors, isSubmitting, handleChange, handleBlur, handleSubmit } = props; + + return ( + +
+ + {errors.password && touched.password && ( +

{errors.password}

+ )} +
+ +
+ + {errors.confirmPassword && touched.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+ +
+ + {errors.acceptTerms && touched.acceptTerms && ( +

{errors.acceptTerms}

+ )} +
+ + + + ); +}; + +interface OnboardingFormProps { + onSubmit: (values: { password: string; makeSecurityAccount: boolean }) => Promise; +} + +const OnboardingForm = withFormik({ + mapPropsToValues: (): OnboardingFormValues => ({ + confirmPassword: '', + password: '', + acceptTerms: false, + makeSecurityAccount: false, + }), + + validationSchema: Yup.object().shape({ + password: Yup.string() + .required('Please input password') + .min(8, 'Password is too short - should be 8 chars minimum.'), + confirmPassword: Yup.string() + .required('Please confirm password') + .min(8, 'Password is too short - should be 8 chars minimum.') + .oneOf([Yup.ref('password'), null], 'Passwords must match'), + acceptTerms: Yup.bool().oneOf([true], 'Accepting Terms & Conditions is required'), + }), + + handleSubmit: (values, { props }) => { + props.onSubmit(values).catch(console.error); + }, + + displayName: 'WalletCreateForm', +})(OnboardingFormView); + +export default OnboardingForm; diff --git a/src/presentation/onboarding/seed-confirm/index.tsx b/src/presentation/onboarding/seed-confirm/index.tsx index 7e0176ba..df551610 100644 --- a/src/presentation/onboarding/seed-confirm/index.tsx +++ b/src/presentation/onboarding/seed-confirm/index.tsx @@ -6,10 +6,10 @@ import { INITIALIZE_END_OF_FLOW_ROUTE } from '../../routes/constants'; import Shell from '../../components/shell'; import { useDispatch } from 'react-redux'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; -import { setVerified } from '../../../application/redux/actions/wallet'; +import { setOnboardingVerified } from '../../../application/redux/actions/onboarding'; +import { INVALID_MNEMONIC_ERROR } from '../../../application/utils/constants'; const NULL_ERROR = ''; -const ERROR_MSG = 'Invalid mnemonic'; export interface SeedConfirmProps { onboardingMnemonic: string; @@ -27,11 +27,11 @@ const SeedConfirmView: React.FC = ({ onboardingMnemonic, isFro const handleConfirm = async () => { if (selected.join(' ') === mnemonic.join(' ')) { - await dispatch(setVerified()); + await dispatch(setOnboardingVerified()); history.push(INITIALIZE_END_OF_FLOW_ROUTE); } - setError(ERROR_MSG); + setError(INVALID_MNEMONIC_ERROR); setSelected([]); setWordsList(mnemonicRandomized); }; diff --git a/src/presentation/onboarding/wallet-create/index.tsx b/src/presentation/onboarding/wallet-create/index.tsx index 1b01cc22..49eec773 100644 --- a/src/presentation/onboarding/wallet-create/index.tsx +++ b/src/presentation/onboarding/wallet-create/index.tsx @@ -1,166 +1,36 @@ +import { generateMnemonic } from 'bip39'; import React from 'react'; -import { RouteComponentProps, useHistory } from 'react-router-dom'; -import { FormikProps, withFormik } from 'formik'; -import * as Yup from 'yup'; -import cx from 'classnames'; -import Button from '../../components/button'; -import Shell from '../../components/shell'; -import { INITIALIZE_SEED_PHRASE_ROUTE, SETTINGS_TERMS_ROUTE } from '../../routes/constants'; import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router'; import { setPasswordAndOnboardingMnemonic } from '../../../application/redux/actions/onboarding'; -import { generateMnemonic } from 'bip39'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; +import Shell from '../../components/shell'; +import { INITIALIZE_SEED_PHRASE_ROUTE } from '../../routes/constants'; +import OnboardingForm from '../onboarding-form'; -interface WalletCreateFormValues { - password: string; - confirmPassword: string; - acceptTerms: boolean; -} - -const WalletCreateForm = (props: FormikProps) => { - const { values, touched, errors, isSubmitting, handleChange, handleBlur, handleSubmit } = props; - - return ( -
-
- - {errors.password && touched.password && ( -

{errors.password}

- )} -
- -
- - {errors.confirmPassword && touched.confirmPassword && ( -

{errors.confirmPassword}

- )} -
- -
- - {errors.acceptTerms && touched.acceptTerms && ( -

{errors.acceptTerms}

- )} -
- - -
- ); -}; - -interface WalletCreateFormProps { - dispatch: ProxyStoreDispatch; - history: RouteComponentProps['history']; -} - -const WalletCreateEnhancedForm = withFormik({ - mapPropsToValues: (): WalletCreateFormValues => ({ - confirmPassword: '', - password: '', - acceptTerms: false, - }), - - validationSchema: Yup.object().shape({ - password: Yup.string() - .required('Please input password') - .min(8, 'Password is too short - should be 8 chars minimum.'), - confirmPassword: Yup.string() - .required('Please confirm password') - .min(8, 'Password is too short - should be 8 chars minimum.') - .oneOf([Yup.ref('password'), null], 'Passwords must match'), - acceptTerms: Yup.bool().oneOf([true], 'Accepting Terms & Conditions is required'), - }), - - handleSubmit: (values, { props }) => { - props - .dispatch(setPasswordAndOnboardingMnemonic(values.password, generateMnemonic())) - .catch(console.error); - props.history.push(INITIALIZE_SEED_PHRASE_ROUTE); - }, - - displayName: 'WalletCreateForm', -})(WalletCreateForm); - -const WalletCreate: React.FC = () => { +const WalletCreate: React.FC = () => { const dispatch = useDispatch(); const history = useHistory(); + const onSubmit = async ({ + password, + makeSecurityAccount, + }: { + password: string; + makeSecurityAccount: boolean; + }) => { + await dispatch( + setPasswordAndOnboardingMnemonic(password, generateMnemonic(), makeSecurityAccount) + ); + history.push(INITIALIZE_SEED_PHRASE_ROUTE); + }; + return (

Create password

- +
); }; -const OpenTerms: React.FC = () => { - const history = useHistory(); - - const handleClick = (e: any) => { - e.preventDefault(); - - history.push({ - pathname: SETTINGS_TERMS_ROUTE, - state: { isFullScreen: true }, - }); - }; - - return ( - /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ - - terms of service - - ); -}; - export default WalletCreate; diff --git a/src/presentation/onboarding/wallet-restore/index.tsx b/src/presentation/onboarding/wallet-restore/index.tsx index 220e129a..6c406f5b 100644 --- a/src/presentation/onboarding/wallet-restore/index.tsx +++ b/src/presentation/onboarding/wallet-restore/index.tsx @@ -1,170 +1,43 @@ -import React from 'react'; -import { useHistory, RouteComponentProps } from 'react-router-dom'; -import cx from 'classnames'; -import { withFormik, FormikProps } from 'formik'; -import * as Yup from 'yup'; -import Button from '../../components/button'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; import Shell from '../../components/shell'; -import { IError, RootReducerState } from '../../../domain/common'; import { INITIALIZE_END_OF_FLOW_ROUTE } from '../../routes/constants'; -import { setPasswordAndOnboardingMnemonic } from '../../../application/redux/actions/onboarding'; -import { useDispatch, useSelector } from 'react-redux'; +import { + setOnboardingVerified, + setPasswordAndOnboardingMnemonic, +} from '../../../application/redux/actions/onboarding'; +import { useDispatch } from 'react-redux'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; -import { setVerified } from '../../../application/redux/actions/wallet'; +import { MnemonicField } from './mnemonic-field'; +import OnboardingForm from '../onboarding-form'; -interface WalletRestoreFormValues { - mnemonic: string; - password: string; - confirmPassword: string; - ctxErrors?: Record; -} - -interface WalletRestoreFormProps { - ctxErrors?: Record; - dispatch: ProxyStoreDispatch; - history: RouteComponentProps['history']; -} - -const WalletRestoreForm = (props: FormikProps) => { - const { values, touched, errors, isSubmitting, handleChange, handleBlur, handleSubmit } = props; - - return ( -
-
-