diff --git a/package.json b/package.json index c39b02ee..4674b663 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "decimal.js": "^10.2.1", "formik": "^2.2.6", "google-protobuf": "^3.15.8", - "ldk": "^0.3.9", + "ldk": "^0.3.15", + "liquidjs-lib": "^5.2.2", "lodash.debounce": "^4.0.8", "lottie-web": "^5.7.8", "marina-provider": "^1.4.3", @@ -47,7 +48,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/src/application/redux/actions/action-types.ts b/src/application/redux/actions/action-types.ts index 62f86954..ab43277e 100644 --- a/src/application/redux/actions/action-types.ts +++ b/src/application/redux/actions/action-types.ts @@ -1,14 +1,15 @@ // Wallet export const WALLET_SET_DATA = 'WALLET_SET_DATA'; +export const SET_RESTORER_OPTS = 'SET_RESTORER_OPTS'; +export const SET_RESTRICTED_ASSET_ACCOUNT = 'SET_RESTRICTED_ASSET_ACCOUNT'; 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'; @@ -27,9 +28,7 @@ export const ONBOARDING_FLUSH = 'ONBOARDING_FLUSH'; export const ONBOARDING_SET_IS_FROM_POPUP_FLOW = 'ONBOARDING_SET_IS_FROM_POPUP_FLOW'; // 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_ASSET = 'PENDING_TX_SET_ASSET'; @@ -51,6 +50,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 @@ -60,9 +60,16 @@ export const RESET_TAXI = 'RESET_TAXI'; // Alarms export const START_PERIODIC_UPDATE = 'START_PERIODIC_UPDATE'; +export const STOP_PERIODIC_UPDATE = 'STOP_PERIODIC_UPDATE'; // Restoration export const START_DEEP_RESTORATION = 'START_DEEP_RESTORATION'; // Reset export const RESET = 'RESET'; + +// Allowance +export const ALLOW_COIN = 'ALLOW_COIN'; + +// Updater taskes +export const UPDATE_TASK = 'UPDATE_TASK'; diff --git a/src/application/redux/actions/allowance.ts b/src/application/redux/actions/allowance.ts new file mode 100644 index 00000000..cef8eb39 --- /dev/null +++ b/src/application/redux/actions/allowance.ts @@ -0,0 +1,25 @@ +import { Outpoint } from 'ldk'; +import { ActionWithPayload } from '../../../domain/common'; +import { AssetAmount } from '../../../domain/connect'; +import { ALLOW_COIN, SET_APPROVE_REQUEST_PARAM } from './action-types'; + +export function addAllowedCoin(utxo: Outpoint): ActionWithPayload { + return { + type: ALLOW_COIN, + payload: { + txid: utxo.txid, + vout: utxo.vout, + }, + }; +} + +export function setApproveParams( + assetAmounts: AssetAmount[] +): ActionWithPayload<{ assetAmounts: AssetAmount[] }> { + return { + type: SET_APPROVE_REQUEST_PARAM, + payload: { + assetAmounts, + }, + }; +} diff --git a/src/application/redux/actions/app.ts b/src/application/redux/actions/app.ts index f2ae1cf3..3129e3bc 100644 --- a/src/application/redux/actions/app.ts +++ b/src/application/redux/actions/app.ts @@ -7,6 +7,7 @@ import { START_PERIODIC_UPDATE, SET_EXPLORER, RESET, + STOP_PERIODIC_UPDATE, } from './action-types'; import { AnyAction } from 'redux'; import { Network } from '../../../domain/network'; @@ -47,6 +48,10 @@ export function startPeriodicUpdate(): AnyAction { return { type: START_PERIODIC_UPDATE }; } +export function stopPeriodicUpdate(): AnyAction { + return { type: STOP_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..c483185c 100644 --- a/src/application/redux/actions/onboarding.ts +++ b/src/application/redux/actions/onboarding.ts @@ -5,10 +5,14 @@ import { } 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 }, }; } diff --git a/src/application/redux/actions/transaction.ts b/src/application/redux/actions/transaction.ts index f90752c9..9b1ea108 100644 --- a/src/application/redux/actions/transaction.ts +++ b/src/application/redux/actions/transaction.ts @@ -4,7 +4,6 @@ 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, ADD_TX, } from './action-types'; @@ -12,6 +11,8 @@ import { AnyAction } from 'redux'; import { Address } from '../../../domain/address'; import { TxDisplayInterface } from '../../../domain/transaction'; import { Network } from '../../../domain/network'; +import { AccountID } from '../../../domain/account'; +import { UtxoInterface } from 'ldk'; export function setAsset(asset: string): AnyAction { return { type: PENDING_TX_SET_ASSET, payload: { asset } }; @@ -40,22 +41,16 @@ 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: UtxoInterface[]): AnyAction { return { type: PENDING_TX_SET_PSET, - payload: { pset }, + payload: { pset, utxos }, }; } -export function addTx(tx: TxDisplayInterface, network: Network): AnyAction { +export function addTx(accountID: AccountID, tx: TxDisplayInterface, network: Network): 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..32a31cf2 --- /dev/null +++ b/src/application/redux/actions/updater.ts @@ -0,0 +1,10 @@ +import { AccountID } from '../../../domain/account'; +import { ActionWithPayload } from '../../../domain/common'; +import { UPDATE_TASK } from './action-types'; + +export type UpdateTaskAction = ActionWithPayload; + +export const updateTaskAction = (accountID: AccountID): UpdateTaskAction => ({ + type: UPDATE_TASK, + payload: accountID, +}); diff --git a/src/application/redux/actions/utxos.ts b/src/application/redux/actions/utxos.ts index e4c69a2e..d3137a2e 100644 --- a/src/application/redux/actions/utxos.ts +++ b/src/application/redux/actions/utxos.ts @@ -1,19 +1,19 @@ import { UtxoInterface } 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: UtxoInterface }>; -export function addUtxo(utxo: UtxoInterface): AnyAction { - return { type: ADD_UTXO, payload: { utxo } }; +export function addUtxo(accountID: AccountID, utxo: UtxoInterface): AddUtxoAction { + return { type: ADD_UTXO, payload: { accountID, utxo } }; } -export function deleteUtxo(txid: string, vout: number): AnyAction { - return { type: DELETE_UTXO, payload: { txid, vout } }; +export function deleteUtxo(accountID: AccountID, txid: string, vout: number): AnyAction { + return { type: DELETE_UTXO, payload: { txid, vout, accountID } }; } -export function flushUtxos(): AnyAction { - return { type: FLUSH_UTXOS }; +export function flushUtxos(accountID: AccountID): AnyAction { + return { type: FLUSH_UTXOS, payload: { accountID } }; } diff --git a/src/application/redux/actions/wallet.ts b/src/application/redux/actions/wallet.ts index d0677fa1..aea8259c 100644 --- a/src/application/redux/actions/wallet.ts +++ b/src/application/redux/actions/wallet.ts @@ -4,13 +4,27 @@ 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_RESTRICTED_ASSET_ACCOUNT, + SET_RESTORER_OPTS, } from './action-types'; import { AnyAction } from 'redux'; import { WalletData } from '../../utils/wallet'; import { extractErrorMessage } from '../../../presentation/utils/error'; +import { AccountID, MultisigAccountData } from '../../../domain/account'; +import { CosignerExtraData } from '../../../domain/wallet'; +import { StateRestorerOpts } from 'ldk'; + +export function setRestrictedAssetData( + multisigAccountData: MultisigAccountData +) { + return { + type: SET_RESTRICTED_ASSET_ACCOUNT, + payload: { multisigAccountData }, + }; +} export function setWalletData(walletData: WalletData): AnyAction { return { @@ -19,12 +33,19 @@ export function setWalletData(walletData: WalletData): AnyAction { }; } -export function incrementAddressIndex(): AnyAction { - return { type: NEW_ADDRESS_SUCCESS }; +export function setRestorerOpts(accountID: AccountID, restorerOpts: StateRestorerOpts): AnyAction { + return { + type: SET_RESTORER_OPTS, + payload: { accountID, restorerOpts }, + }; +} + +export function incrementAddressIndex(accountID: AccountID): AnyAction { + return { type: INCREMENT_EXTERNAL_ADDRESS_INDEX, payload: { accountID } }; } -export function incrementChangeAddressIndex(): AnyAction { - return { type: NEW_CHANGE_ADDRESS_SUCCESS }; +export function incrementChangeAddressIndex(accountID: AccountID): AnyAction { + return { type: INCREMENT_INTERNAL_ADDRESS_INDEX, payload: { accountID } }; } export function setDeepRestorerIsLoading(isLoading: boolean): AnyAction { diff --git a/src/application/redux/containers/address-amount.container.ts b/src/application/redux/containers/address-amount.container.ts index a932d774..f2da6026 100644 --- a/src/application/redux/containers/address-amount.container.ts +++ b/src/application/redux/containers/address-amount.container.ts @@ -1,19 +1,19 @@ import { connect } from 'react-redux'; +import { MainAccountID, RestrictedAssetAccountID } 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, RestrictedAssetAccountID)(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..f1bcea83 100644 --- a/src/application/redux/containers/choose-fee.container.ts +++ b/src/application/redux/containers/choose-fee.container.ts @@ -1,23 +1,23 @@ import { connect } from 'react-redux'; +import { MainAccountID, RestrictedAssetAccountID } 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 { selectBalances } from '../selectors/balance.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), + balances: selectBalances(MainAccountID, RestrictedAssetAccountID)(state), taxiAssets: state.taxi.taxiAssets, lbtcAssetHash: lbtcAssetByNetwork(state.app.network), - masterPubKey: masterPubKeySelector(state), - restorerOpts: restorerOptsSelector(state), sendAddress: state.transaction.sendAddress, changeAddress: state.transaction.changeAddress, sendAsset: state.transaction.sendAsset, sendAmount: state.transaction.sendAmount, + account: selectMainAccount(state), + utxos: selectUtxos(MainAccountID, RestrictedAssetAccountID)(state), }); const ChooseFee = connect(mapStateToProps)(ChooseFeeView); diff --git a/src/application/redux/containers/cosigners.container.ts b/src/application/redux/containers/cosigners.container.ts new file mode 100644 index 00000000..b25c5b4d --- /dev/null +++ b/src/application/redux/containers/cosigners.container.ts @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { RootReducerState } from '../../../domain/common'; +import SettingsCosignersView, { + SettingsCosignersProps, +} from '../../../presentation/settings/cosigners'; + +const SettingsCosigner = connect( + (state: RootReducerState): SettingsCosignersProps => ({ + multisigAccountsData: state.wallet.restrictedAssetAccount + ? [state.wallet.restrictedAssetAccount] + : [], + }) +)(SettingsCosignersView); + +export default SettingsCosigner; 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 c4dee69a..6f35d106 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, explorerURL: getExplorerURLSelector(state), hasMnemonicRegistered: hasMnemonicSelector(state), + needSecurityAccount: state.onboarding.needSecurityAccount, }; }; diff --git a/src/application/redux/containers/end-of-flow.container.ts b/src/application/redux/containers/end-of-flow.container.ts index 4f46a882..d8a380bf 100644 --- a/src/application/redux/containers/end-of-flow.container.ts +++ b/src/application/redux/containers/end-of-flow.container.ts @@ -2,14 +2,14 @@ import { connect } from 'react-redux'; import { RootReducerState } from '../../../domain/common'; import EndOfFlow, { EndOfFlowProps } from '../../../presentation/wallet/send/end-of-flow'; import { getExplorerURLSelector } from '../selectors/app.selector'; +import { selectAllAccounts } from '../selectors/wallet.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: getExplorerURLSelector(state), recipientAddress: state.transaction.sendAddress?.value, + selectedUtxos: state.transaction.selectedUtxos ?? [], }); 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..3c7e3152 100644 --- a/src/application/redux/containers/home.container.ts +++ b/src/application/redux/containers/home.container.ts @@ -1,15 +1,16 @@ 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 { MainAccountID, RestrictedAssetAccountID } from '../../../domain/account'; const mapStateToProps = (state: RootReducerState): HomeProps => ({ lbtcAssetHash: lbtcAssetByNetwork(state.app.network), network: state.app.network, transactionStep: state.transaction.step, - assetsBalance: balancesSelector(state), + assetsBalance: selectBalances(MainAccountID, RestrictedAssetAccountID)(state), getAsset: assetGetterFromIAssets(state.assets), isWalletVerified: state.wallet.isVerified, }); diff --git a/src/application/redux/containers/pair.container.ts b/src/application/redux/containers/pair.container.ts new file mode 100644 index 00000000..f2d5d289 --- /dev/null +++ b/src/application/redux/containers/pair.container.ts @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { RootReducerState } from '../../../domain/common'; +import PairCosignerView, { PairCosignerProps } from '../../../presentation/cosigner/pair'; +import { getExplorerURLSelector } from '../selectors/app.selector'; + +const mapStateToProps = (state: RootReducerState): PairCosignerProps => ({ + encryptedMnemonic: state.wallet.mainAccount.encryptedMnemonic, + explorerURL: getExplorerURLSelector(state), + network: state.app.network, +}); + +const PairCosigner = connect(mapStateToProps)(PairCosignerView); + +export default PairCosigner; diff --git a/src/application/redux/containers/receive-select-asset.container.ts b/src/application/redux/containers/receive-select-asset.container.ts index 7dbdea4b..fcefdccc 100644 --- a/src/application/redux/containers/receive-select-asset.container.ts +++ b/src/application/redux/containers/receive-select-asset.container.ts @@ -1,17 +1,19 @@ import { connect } from 'react-redux'; +import { MainAccountID, RestrictedAssetAccountID } 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, RestrictedAssetAccountID)(state); const getAsset = assetGetterFromIAssets(state.assets); return { network: state.app.network, assets: Object.keys(balances).map(getAsset), + restrictedAssetSetup: state.wallet.restrictedAssetAccount !== undefined, }; }; 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..ea2b6cc6 100644 --- a/src/application/redux/containers/send-select-asset.container.ts +++ b/src/application/redux/containers/send-select-asset.container.ts @@ -1,13 +1,14 @@ import { connect } from 'react-redux'; +import { MainAccountID, RestrictedAssetAccountID } 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, RestrictedAssetAccountID)(state); const getAsset = assetGetterFromIAssets(state.assets); return { network: state.app.network, diff --git a/src/application/redux/containers/settings-networks.container.ts b/src/application/redux/containers/settings-networks.container.ts index 7a6050a9..27df4e77 100644 --- a/src/application/redux/containers/settings-networks.container.ts +++ b/src/application/redux/containers/settings-networks.container.ts @@ -3,9 +3,11 @@ import SettingsNetworksView, { } from './../../../presentation/settings/networks'; import { connect } from 'react-redux'; import { RootReducerState } from '../../../domain/common'; +import { MainAccountID, RestrictedAssetAccountID } from '../../../domain/account'; const mapStateToProps = (state: RootReducerState): SettingsNetworksProps => ({ restorationLoading: state.wallet.deepRestorer.isLoading, + accountsIDs: [RestrictedAssetAccountID, MainAccountID], error: state.wallet.deepRestorer.error, }); diff --git a/src/application/redux/containers/transactions.container.ts b/src/application/redux/containers/transactions.container.ts index 5c3a0ec9..961c760e 100644 --- a/src/application/redux/containers/transactions.container.ts +++ b/src/application/redux/containers/transactions.container.ts @@ -1,12 +1,13 @@ import { connect } from 'react-redux'; +import { MainAccountID, RestrictedAssetAccountID } from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; import TransactionsView, { TransactionsProps } from '../../../presentation/wallet/transactions'; -import { walletTransactions } from '../selectors/transaction.selector'; +import { selectTransactions } from '../selectors/wallet.selector'; const mapStateToProps = (state: RootReducerState): TransactionsProps => ({ assets: state.assets, network: state.app.network, - transactions: walletTransactions(state), + transactions: selectTransactions(MainAccountID, RestrictedAssetAccountID)(state), webExplorerURL: state.app.explorerByNetwork[state.app.network].electrsURL, }); diff --git a/src/application/redux/reducers/allowance-reducer.ts b/src/application/redux/reducers/allowance-reducer.ts new file mode 100644 index 00000000..93c36903 --- /dev/null +++ b/src/application/redux/reducers/allowance-reducer.ts @@ -0,0 +1,28 @@ +import { Outpoint } from 'ldk'; +import { AnyAction } from 'redux'; +import { ALLOW_COIN } from '../actions/action-types'; + +export interface AllowanceState { + allowed: Outpoint[]; +} + +export const allowanceInitState: AllowanceState = { + allowed: [], +}; + +export function allowanceReducer( + state: AllowanceState = allowanceInitState, + { type, payload }: AnyAction +): AllowanceState { + switch (type) { + case ALLOW_COIN: { + return { + ...state, + allowed: state.allowed.concat([{ txid: payload.txid, vout: payload.vout }]), + }; + } + + default: + return state; + } +} diff --git a/src/application/redux/reducers/asset-reducer.ts b/src/application/redux/reducers/asset-reducer.ts index 1363111d..a4324f1f 100644 --- a/src/application/redux/reducers/asset-reducer.ts +++ b/src/application/redux/reducers/asset-reducer.ts @@ -17,7 +17,6 @@ export const assetInitState: IAssets = { name: 'Liquid Bitcoin', precision: 8, ticker: 'L-BTC', - isRegtestAsset: true, }, }; diff --git a/src/application/redux/reducers/connect-data-reducer.ts b/src/application/redux/reducers/connect-data-reducer.ts index e6a7805e..c08acb9d 100644 --- a/src/application/redux/reducers/connect-data-reducer.ts +++ b/src/application/redux/reducers/connect-data-reducer.ts @@ -71,6 +71,15 @@ export function connectDataReducer( }; } + case ACTION_TYPES.SET_APPROVE_REQUEST_PARAM: { + return { + ...state, + allowance: { + requestParam: payload.assetAmounts, + }, + }; + } + default: return state; } diff --git a/src/application/redux/reducers/index.ts b/src/application/redux/reducers/index.ts index 53c58d74..0235946d 100644 --- a/src/application/redux/reducers/index.ts +++ b/src/application/redux/reducers/index.ts @@ -1,15 +1,13 @@ 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 { 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'; @@ -17,6 +15,7 @@ import { PersistConfig } from 'redux-persist/lib/types'; import { appReducer, appInitState } from './app-reducer'; import { walletInitState, walletReducer } from './wallet-reducer'; import { connectDataReducer, connectDataInitState } from './connect-data-reducer'; +import { allowanceInitState, allowanceReducer, AllowanceState } from './allowance-reducer'; const browserLocalStorage: Storage = { getItem: async (key: string) => { @@ -88,17 +87,11 @@ const marinaReducer = combineReducers({ version: 1, initialState: transactionInitState, }), - txsHistory: persist({ - reducer: txsHistoryReducer, - key: 'txsHistory', - version: 2, - initialState: txsHistoryInitState, - }), - wallet: persist({ + wallet: persist({ reducer: walletReducer, key: 'wallet', blacklist: ['deepRestorer'], - version: 1, + version: 3, initialState: walletInitState, }), taxi: persist({ @@ -114,6 +107,12 @@ const marinaReducer = combineReducers({ version: 1, initialState: connectDataInitState, }), + allowance: persist({ + reducer: allowanceReducer, + key: 'allowance', + version: 0, + initialState: allowanceInitState, + }), }); export default marinaReducer; diff --git a/src/application/redux/reducers/onboarding-reducer.ts b/src/application/redux/reducers/onboarding-reducer.ts index 58c99ace..f2b8eb52 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; + needSecurityAccount: boolean; } const onboardingInitState: OnboardingState = { mnemonic: '', password: '', isFromPopupFlow: false, + needSecurityAccount: false, }; export function onboardingReducer( @@ -23,6 +25,7 @@ export function onboardingReducer( ...state, password: payload.password, mnemonic: payload.mnemonic, + needSecurityAccount: payload.needSecurityAccount, }; } diff --git a/src/application/redux/reducers/transaction-reducer.ts b/src/application/redux/reducers/transaction-reducer.ts index 4e41941b..a20f6b6a 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 { UtxoInterface } from 'ldk'; export type PendingTxStep = 'empty' | 'address-amount' | 'choose-fee' | 'confirmation'; @@ -14,6 +15,7 @@ export interface TransactionState { sendAddress?: Address; changeAddress?: Address; feeChangeAddress?: Address; + selectedUtxos?: UtxoInterface[]; } export const transactionInitState: TransactionState = { @@ -73,6 +75,7 @@ export function transactionReducer( ...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 5b89879b..00000000 --- a/src/application/redux/reducers/txs-history-reducer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as ACTION_TYPES from '../actions/action-types'; -import { TxDisplayInterface, TxsHistoryByNetwork } from '../../../domain/transaction'; -import { AnyAction } from 'redux'; - -export const txsHistoryInitState: TxsHistoryByNetwork = { regtest: {}, liquid: {} }; - -export function txsHistoryReducer( - state: TxsHistoryByNetwork = txsHistoryInitState, - { type, payload }: AnyAction -): TxsHistoryByNetwork { - switch (type) { - case ACTION_TYPES.RESET_TXS: { - return txsHistoryInitState; - } - - case ACTION_TYPES.ADD_TX: { - let newLiquidTxsHistory = state.liquid; - let newRegtestTxsHistory = state.regtest; - const toAddTx = payload.tx as TxDisplayInterface; - if (payload.network === 'liquid') { - newLiquidTxsHistory = { ...state.liquid, [toAddTx.txId]: toAddTx }; - } else { - newRegtestTxsHistory = { ...state.regtest, [toAddTx.txId]: toAddTx }; - } - return { regtest: newRegtestTxsHistory, liquid: newLiquidTxsHistory }; - } - - default: - return state; - } -} diff --git a/src/application/redux/reducers/wallet-reducer.ts b/src/application/redux/reducers/wallet-reducer.ts index 3b7effc1..30f42555 100644 --- a/src/application/redux/reducers/wallet-reducer.ts +++ b/src/application/redux/reducers/wallet-reducer.ts @@ -1,20 +1,40 @@ /* 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 { CosignerExtraData, WalletState } from '../../../domain/wallet'; import { AnyAction } from 'redux'; import { UtxoInterface } from 'ldk'; +import { + AccountID, + MainAccountID, + MultisigAccountData, + RestrictedAssetAccountID, +} from '../../../domain/account'; +import { TxDisplayInterface } from '../../../domain/transaction'; +import { Network } from '../../../domain/network'; -export const walletInitState: IWallet = { - restorerOpts: { - lastUsedExternalIndex: 0, - lastUsedInternalIndex: 0, +export const walletInitState: WalletState = { + [MainAccountID]: { + encryptedMnemonic: '', + masterBlindingKey: '', + masterXPub: '', + restorerOpts: { + lastUsedExternalIndex: 0, + lastUsedInternalIndex: 0, + }, + }, + [RestrictedAssetAccountID]: undefined, + unspentsAndTransactions: { + [MainAccountID]: { + utxosMap: {}, + transactions: { regtest: {}, liquid: {} }, + }, + [RestrictedAssetAccountID]: { + utxosMap: {}, + transactions: { regtest: {}, liquid: {} }, + }, }, - encryptedMnemonic: '', - masterXPub: '', - masterBlindingKey: '', passwordHash: '', - utxoMap: {}, deepRestorer: { gapLimit: 20, isLoading: false, @@ -22,67 +42,153 @@ export const walletInitState: IWallet = { isVerified: false, }; +const addUnspent = + (state: WalletState) => + (accountID: AccountID, utxo: UtxoInterface): WalletState => { + return { + ...state, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + utxosMap: { + ...state.unspentsAndTransactions[accountID].utxosMap, + [toStringOutpoint(utxo)]: utxo, + }, + }, + }, + }; + }; + +const addTx = + (state: WalletState) => + (accountID: AccountID, tx: TxDisplayInterface, network: Network): WalletState => { + return { + ...state, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + transactions: { + ...state.unspentsAndTransactions[accountID].transactions, + [network]: { + ...state.unspentsAndTransactions[accountID].transactions[network], + [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.SET_RESTORER_OPTS: { + return { + ...state, + [payload.accountID]: { + ...state[payload.accountID as AccountID], + restorerOpts: payload.restorerOpts, + }, + }; + } + case ACTION_TYPES.WALLET_SET_DATA: { return { ...state, - masterXPub: payload.masterXPub, - masterBlindingKey: payload.masterBlindingKey, - encryptedMnemonic: payload.encryptedMnemonic, passwordHash: payload.passwordHash, - restorerOpts: payload.restorerOpts, + mainAccount: { accountID: MainAccountID, ...payload }, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [MainAccountID]: { + utxosMap: {}, + transactions: { regtest: {}, liquid: {} }, + }, + }, }; } - case ACTION_TYPES.NEW_CHANGE_ADDRESS_SUCCESS: { + case ACTION_TYPES.SET_RESTRICTED_ASSET_ACCOUNT: { + const data = payload.multisigAccountData as MultisigAccountData; return { ...state, - restorerOpts: { - ...state.restorerOpts, - lastUsedInternalIndex: (state.restorerOpts.lastUsedInternalIndex ?? -1) + 1, + restrictedAssetAccount: data, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [RestrictedAssetAccountID]: { + utxosMap: {}, + transactions: { liquid: {}, regtest: {} }, + }, }, }; } - case ACTION_TYPES.NEW_ADDRESS_SUCCESS: { + case ACTION_TYPES.INCREMENT_INTERNAL_ADDRESS_INDEX: { + const accountID = payload.accountID as AccountID; return { ...state, - restorerOpts: { - ...state.restorerOpts, - lastUsedExternalIndex: (state.restorerOpts.lastUsedExternalIndex ?? -1) + 1, + [accountID]: { + ...state[accountID], + restorerOpts: { + ...state[accountID]?.restorerOpts, + lastUsedInternalIndex: (state[accountID]?.restorerOpts.lastUsedInternalIndex ?? 0) + 1, + }, }, }; } - case ACTION_TYPES.ADD_UTXO: { + case ACTION_TYPES.INCREMENT_EXTERNAL_ADDRESS_INDEX: { + const accountID = payload.accountID as AccountID; return { ...state, - utxoMap: { - ...state.utxoMap, - [toStringOutpoint(payload.utxo as UtxoInterface)]: payload.utxo, + [accountID]: { + ...state[accountID], + restorerOpts: { + ...state[accountID]?.restorerOpts, + lastUsedExternalIndex: (state[accountID]?.restorerOpts.lastUsedExternalIndex ?? 0) + 1, + }, }, }; } + case ACTION_TYPES.ADD_UTXO: { + return addUnspent(state)(payload.accountID, payload.utxo); + } + case ACTION_TYPES.DELETE_UTXO: { + const accountID = payload.accountID as AccountID; + if (!state.unspentsAndTransactions[accountID]) { + return state; + } + const { [toStringOutpoint({ txid: payload.txid, vout: payload.vout })]: deleted, - ...utxoMap - } = state.utxoMap; + ...utxosMap + } = state.unspentsAndTransactions[accountID].utxosMap; + return { ...state, - utxoMap, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [payload.accountID]: { + ...state.unspentsAndTransactions[accountID], + 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, @@ -105,9 +211,16 @@ export function walletReducer( } case ACTION_TYPES.FLUSH_UTXOS: { + const accountID = payload.accountID as AccountID; return { ...state, - utxoMap: {}, + unspentsAndTransactions: { + ...state.unspentsAndTransactions, + [accountID]: { + ...state.unspentsAndTransactions[accountID], + utxosMap: {}, + }, + }, }; } diff --git a/src/application/redux/sagas/deep-restorer.ts b/src/application/redux/sagas/deep-restorer.ts new file mode 100644 index 00000000..9957be03 --- /dev/null +++ b/src/application/redux/sagas/deep-restorer.ts @@ -0,0 +1,85 @@ +import { + AddressInterface, + EsploraRestorerOpts, + IdentityInterface, + 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'; +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, +} from './utils'; + +function* getDeepRestorerSaga( + account: Account +): SagaGenerator> { + return yield call(() => account.getDeepRestorer()); +} + +function* restoreSaga( + restorer: Restorer, + arg: EsploraRestorerOpts +): SagaGenerator { + const restoreAddresses = () => restorer(arg).then((id) => id.getAddresses()); + return yield call(restoreAddresses); +} + +function* deepRestore( + accountID: AccountID, + gapLimit: number, + esploraURL: string +): SagaGenerator { + const account = yield* selectAccountSaga(accountID); + if (!account) throw new Error('Account not found'); + + const restorer = yield* getDeepRestorerSaga(account); + const restoredAddresses = yield* restoreSaga(restorer, { gapLimit, esploraURL }); + const stateRestorerOpts = getStateRestorerOptsFromAddresses(restoredAddresses); + return stateRestorerOpts; +} + +const selectDeepRestorerIsLoadingSaga = newSagaSelector(selectDeepRestorerIsLoading); +const selectDeepRestorerGapLimitSaga = newSagaSelector(selectDeepRestorerGapLimit); + +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 stateRestorerOpts = yield* deepRestore(ID, gapLimit, esploraURL); + yield put(setRestorerOpts(ID, stateRestorerOpts)); + yield put(updateTaskAction(ID)); // update utxos and transactions according to the restored addresses + } + 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..36dc0607 --- /dev/null +++ b/src/application/redux/sagas/main.ts @@ -0,0 +1,102 @@ +import { + call, + put, + takeLeading, + fork, + all, + take, + cancel, + delay, + AllEffect, +} from 'redux-saga/effects'; +import { fetchAssetsFromTaxi, taxiURL } from '../../utils'; +import { + RESET, + RESET_APP, + RESET_CONNECT, + RESET_TAXI, + RESET_WALLET, + START_PERIODIC_UPDATE, + STOP_PERIODIC_UPDATE, + UPDATE_TAXI_ASSETS, +} from '../actions/action-types'; +import { setTaxiAssets } from '../actions/taxi'; +import { selectTaxiAssets } from '../selectors/taxi.selector'; +import { updateTaskAction } from '../actions/updater'; +import { + newSagaSelector, + SagaGenerator, + selectAllAccountsIDsSaga, + selectNetworkSaga, +} from './utils'; +import { watchUpdateTask } from './updater'; +import { watchStartDeepRestorer } from './deep-restorer'; +import { Task } from 'redux-saga'; + +const selectTaxiAssetsSaga = newSagaSelector(selectTaxiAssets); + +function* fetchAndSetTaxiAssets(): SagaGenerator { + const network = yield* selectNetworkSaga(); + const assets = yield call(fetchAssetsFromTaxi, taxiURL[network]); + const currentTaxiAssets = yield* selectTaxiAssetsSaga(); + const sortAndJoin = (a: string[]) => a.sort().join(''); + if (sortAndJoin(assets) !== sortAndJoin(currentTaxiAssets)) { + yield put(setTaxiAssets(assets)); + } +} + +// 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 newPeriodicSagaTask(task: () => SagaGenerator, intervalMs: number) { + return function* (): SagaGenerator { + while (true) { + yield* task(); + yield delay(intervalMs); + } + }; +} + +function* dispatchUpdateTaskForAllAccountsIDs(): SagaGenerator { + const accountIDs = yield* selectAllAccountsIDsSaga(); + yield all(accountIDs.map((id) => put(updateTaskAction(id)))); +} + +const periodicUpdaterSaga = newPeriodicSagaTask(dispatchUpdateTaskForAllAccountsIDs, 60_000); +const periodicTaxiUpdateSaga = newPeriodicSagaTask(fetchAndSetTaxiAssets, 120_000); + +// watch for every START_PERIODIC_UPDATE actions +// and starts periodic tasks for all accounts + taxi +function* watchPeriodicUpdater(): SagaGenerator { + while (yield take(START_PERIODIC_UPDATE)) { + const periodicUpdateTask = yield fork(periodicUpdaterSaga); + const periodicTaxiUpdateTask = yield fork(periodicTaxiUpdateSaga); + yield take(STOP_PERIODIC_UPDATE); + yield cancel(periodicUpdateTask); + yield cancel(periodicTaxiUpdateTask); + } +} + +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(watchPeriodicUpdater); + 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..2811eb6f --- /dev/null +++ b/src/application/redux/sagas/updater.ts @@ -0,0 +1,211 @@ +import { + UtxoInterface, + Outpoint, + fetchAndUnblindUtxosGenerator, + AddressInterface, + TxInterface, + address, + networks, + BlindingKeyGetter, + fetchAndUnblindTxsGenerator, +} from 'ldk'; +import { put, call, fork, all, take, AllEffect, takeEvery } from 'redux-saga/effects'; +import { buffers, Channel, channel } from '@redux-saga/core'; +import { Account, AccountID } from '../../../domain/account'; +import { Network } from '../../../domain/network'; +import { UtxosAndTxsHistory } from '../../../domain/transaction'; +import { defaultPrecision, toDisplayTransaction, toStringOutpoint } from '../../utils'; +import { addTx } from '../actions/transaction'; +import { addUtxo, AddUtxoAction, deleteUtxo } from '../actions/utxos'; +import { selectUnspentsAndTransactions } from '../selectors/wallet.selector'; +import { + newSagaSelector, + processAsyncGenerator, + SagaGenerator, + selectAccountSaga, + selectExplorerSaga, + selectNetworkSaga, +} from './utils'; +import { ADD_UTXO, UPDATE_TASK } from '../actions/action-types'; +import { Asset, IAssets } from '../../../domain/assets'; +import axios from 'axios'; +import { RootReducerState } from '../../../domain/common'; +import { addAsset } from '../actions/asset'; +import { UpdateTaskAction } from '../actions/updater'; + +function selectUnspentsAndTransactionsSaga( + accountID: AccountID +): SagaGenerator { + return newSagaSelector(selectUnspentsAndTransactions(accountID))(); +} + +const putAddUtxoAction = (accountID: AccountID) => + function* (utxo: UtxoInterface): SagaGenerator { + if (utxo.asset && utxo.value) { + yield put(addUtxo(accountID, utxo)); + } + }; + +const putDeleteUtxoAction = (accountID: AccountID) => + function* (outpoint: Outpoint): SagaGenerator { + yield put(deleteUtxo(accountID, outpoint.txid, outpoint.vout)); + }; + +function* getAddressesFromAccount(account: Account): SagaGenerator { + const getAddresses = () => account.getWatchIdentity().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 +): SagaGenerator> { + const account = yield* selectAccountSaga(accountID); + if (!account) return; + const explorerURL = yield* selectExplorerSaga(); + const utxosTransactionsState = yield* selectUnspentsAndTransactionsSaga(accountID); + const utxosMap = utxosTransactionsState.utxosMap ?? {}; + const addresses = yield* getAddressesFromAccount(account); + 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)); + + const toDelete = Object.values(utxosMap).filter( + (utxo) => !skippedOutpoints.includes(toStringOutpoint(utxo)) + ); + + for (const utxo of toDelete) { + yield* putDeleteUtxoAction(accountID)(utxo); + } +} + +const putAddTxAction = (accountID: AccountID, network: Network, 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): SagaGenerator> { + const account = yield* selectAccountSaga(accountID); + if (!account) return; + const explorerURL = yield* selectExplorerSaga(); + const network = yield* selectNetworkSaga(); + const utxosTransactionsState = yield* selectUnspentsAndTransactionsSaga(accountID); + const txsHistory = utxosTransactionsState.transactions[network] ?? {}; + const addresses = yield* getAddressesFromAccount(account); + + 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): Generator, void, any> { + yield all([txsUpdater(accountID), utxosUpdater(accountID)]); +} + +function* createChannel(): SagaGenerator> { + return yield call(channel, buffers.sliding(10)); +} + +function* requestAssetInfoFromEsplora(assetHash: string): SagaGenerator { + const explorerURL = yield* selectExplorerSaga(); + const getRequest = () => axios.get(`${explorerURL}/asset/${assetHash}`).then((r) => r.data); + const result = yield call(getRequest); + + return { + name: result?.name ?? 'Unknown', + ticker: result?.name ?? assetHash.slice(0, 4).toUpperCase(), + precision: result?.precision ?? defaultPrecision, + }; +} + +function* updaterWorker(chanToListen: Channel): SagaGenerator { + while (true) { + const accountID = yield take(chanToListen); + yield* updateTxsAndUtxos(accountID); + } +} + +const selectAllAssetsSaga = newSagaSelector( + (state: RootReducerState) => new Set(Object.keys(state.assets)) +); + +function* assetsWorker(assetsChan: Channel): SagaGenerator { + while (true) { + const assetHashFromUpdater = yield take(assetsChan); + const assets = yield* selectAllAssetsSaga(); + if (!assets.has(assetHashFromUpdater)) { + const asset = yield* requestAssetInfoFromEsplora(assetHashFromUpdater); + yield put(addAsset(assetHashFromUpdater, asset)); + } + } +} + +export function* watchForAddUtxoAction(chan: Channel): SagaGenerator { + while (true) { + const action = yield take(ADD_UTXO); + const asset = action.payload.utxo.asset; + if (asset) { + yield put(chan, asset); + } + } +} + +// 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 assetsHashChan = yield* createChannel(); + yield fork(assetsWorker, assetsHashChan); + yield fork(watchForAddUtxoAction, assetsHashChan); // this will fee the assets chan + + // listen for UPDATE_TASK + while (true) { + const { payload } = yield take(UPDATE_TASK); + yield put(accountToUpdateChan, payload); + } +} diff --git a/src/application/redux/sagas/utils.ts b/src/application/redux/sagas/utils.ts new file mode 100644 index 00000000..0ef5b883 --- /dev/null +++ b/src/application/redux/sagas/utils.ts @@ -0,0 +1,46 @@ +import { StrictEffect, select, call } from 'redux-saga/effects'; +import { Account, AccountID } from '../../../domain/account'; +import { RootReducerState } from '../../../domain/common'; +import { getExplorerURLSelector, selectNetwork } from '../selectors/app.selector'; +import { selectAccount, selectAllAccountsIDs } from '../selectors/wallet.selector'; + +export type SagaGenerator = Generator< + StrictEffect, + ReturnType, + YieldType +>; + +// 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 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 const selectNetworkSaga = newSagaSelector(selectNetwork); +export const selectAllAccountsIDsSaga = newSagaSelector(selectAllAccountsIDs); +export const selectExplorerSaga = newSagaSelector(getExplorerURLSelector); + +export function selectAccountSaga(accountID: AccountID): SagaGenerator { + return newSagaSelector(selectAccount(accountID))(); +} diff --git a/src/application/redux/selectors/app.selector.ts b/src/application/redux/selectors/app.selector.ts index f63a3cd6..76b81b65 100644 --- a/src/application/redux/selectors/app.selector.ts +++ b/src/application/redux/selectors/app.selector.ts @@ -3,3 +3,7 @@ import { RootReducerState } from './../../../domain/common'; export function getExplorerURLSelector(state: RootReducerState) { return state.app.explorerByNetwork[state.app.network].esploraURL; } + +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 a178eb45..3074c09a 100644 --- a/src/application/redux/selectors/balance.selector.ts +++ b/src/application/redux/selectors/balance.selector.ts @@ -1,40 +1,53 @@ +import { AccountID } from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; import { lbtcAssetByNetwork } from '../../utils'; -import { walletTransactions } from './transaction.selector'; +import { sumBalances } from '../../utils/balances'; +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 = utxos.reduce((acc, curr) => { - if (!curr.asset || !curr.value) { - return acc; - } - return { ...acc, [curr.asset]: curr.value + (curr.asset in acc ? acc[curr.asset] : 0) }; - }, {} as BalancesByAsset); - - const txs = walletTransactions(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 selectBalancesForAccount = + (accountID: AccountID) => + (state: RootReducerState): BalancesByAsset => { + const utxos = selectUtxos(accountID)(state); + console.log(utxos, accountID); + const balancesFromUtxos = utxos.reduce((acc, curr) => { + if (!curr.asset || !curr.value) { + return acc; + } + return { ...acc, [curr.asset]: curr.value + (curr.asset in acc ? acc[curr.asset] : 0) }; + }, {} as BalancesByAsset); + + 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 (balancesFromUtxos[lbtcAssetHash] === undefined) { - 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..99cae34b --- /dev/null +++ b/src/application/redux/selectors/taxi.selector.ts @@ -0,0 +1,5 @@ +import { RootReducerState } from '../../../domain/common'; + +export function selectTaxiAssets(state: RootReducerState): string[] { + return state.taxi.taxiAssets; +} diff --git a/src/application/redux/selectors/transaction.selector.ts b/src/application/redux/selectors/transaction.selector.ts index d742b06b..67b9fe4e 100644 --- a/src/application/redux/selectors/transaction.selector.ts +++ b/src/application/redux/selectors/transaction.selector.ts @@ -1,10 +1,6 @@ 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 => { diff --git a/src/application/redux/selectors/wallet.selector.ts b/src/application/redux/selectors/wallet.selector.ts index d2b44759..0302fa2c 100644 --- a/src/application/redux/selectors/wallet.selector.ts +++ b/src/application/redux/selectors/wallet.selector.ts @@ -1,29 +1,109 @@ -import { IdentityType, MasterPublicKey, StateRestorerOpts, UtxoInterface } from 'ldk'; +import { MasterPublicKey, UtxoInterface } from 'ldk'; +import { + AccountID, + createMnemonicAccount, + createMultisigAccount, + MultisigAccount, + MnemonicAccount, + MainAccountID, + Account, +} from '../../../domain/account'; import { RootReducerState } from '../../../domain/common'; +import { TxDisplayInterface } 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(); } -export function restorerOptsSelector(state: RootReducerState): StateRestorerOpts { - return state.wallet.restorerOpts; +export const selectUtxos = + (...accounts: AccountID[]) => + (state: RootReducerState): UtxoInterface[] => { + return accounts.flatMap((ID) => selectUtxosForAccount(ID)(state)); + }; + +const selectUtxosForAccount = + (accountID: AccountID) => + (state: RootReducerState): UtxoInterface[] => { + return Object.values(selectUnspentsAndTransactions(accountID)(state).utxosMap); + }; + +export const selectTransactions = + (...accounts: AccountID[]) => + (state: RootReducerState) => { + return accounts.flatMap((ID) => selectTransactionsForAccount(ID)(state)); + }; + +const selectTransactionsForAccount = + (accountID: AccountID) => + (state: RootReducerState): TxDisplayInterface[] => { + return Object.values( + selectUnspentsAndTransactions(accountID)(state).transactions[state.app.network] + ); + }; + +export function hasMnemonicSelector(state: RootReducerState): boolean { + return ( + state.wallet.mainAccount.encryptedMnemonic !== '' && + state.wallet.mainAccount.encryptedMnemonic !== undefined + ); } -export function utxosSelector(state: RootReducerState): UtxoInterface[] { - return Object.values(state.wallet.utxoMap); +export function selectMainAccount(state: RootReducerState): MnemonicAccount { + return createMnemonicAccount(state.wallet.mainAccount, state.app.network); } -export function hasMnemonicSelector(state: RootReducerState): boolean { - return state.wallet.encryptedMnemonic !== '' && state.wallet.encryptedMnemonic !== undefined; +export function selectRestrictedAssetAccount(state: RootReducerState): MultisigAccount | undefined { + if (!state.wallet.restrictedAssetAccount) return undefined; + + return createMultisigAccount( + state.wallet.mainAccount.encryptedMnemonic, + state.wallet.restrictedAssetAccount + ); } + +export const selectAllAccounts = (state: RootReducerState): Account[] => { + const mainAccount = selectMainAccount(state); + const restrictedAssetAccount = selectRestrictedAssetAccount(state); + + if (restrictedAssetAccount) { + return [mainAccount, restrictedAssetAccount]; + } + + return [mainAccount]; +}; + +export const selectAllAccountsIDs = (state: RootReducerState): AccountID[] => { + return selectAllAccounts(state).map((account) => account.getAccountID()); +}; + +export const selectAccount = ( + accountID: AccountID +): ((state: RootReducerState) => Account | undefined) => + accountID === MainAccountID ? selectMainAccount : selectRestrictedAssetAccount; + +export const selectAccountForAsset = (asset: string) => (state: RootReducerState) => { + // TODO hardcode restricted asset hashes + if (asset === 'restricted_asset') { + return selectRestrictedAssetAccount(state); + } + + return selectMainAccount(state); +}; + +export const selectUnspentsAndTransactions = + (accountID: AccountID) => (state: RootReducerState) => { + return ( + state.wallet.unspentsAndTransactions[accountID] ?? { + utxosMap: {}, + transactions: { regtest: {}, liquid: {} }, + } + ); + }; + +export const selectDeepRestorerIsLoading = (state: RootReducerState) => { + return state.wallet.deepRestorer.isLoading; +}; + +export const selectDeepRestorerGapLimit = (state: RootReducerState) => { + return state.wallet.deepRestorer.gapLimit; +}; 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 3ad39306..dfeefb36 100644 --- a/src/application/utils/address.ts +++ b/src/application/utils/address.ts @@ -1,9 +1,5 @@ import { address, networks } 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/constants.ts b/src/application/utils/constants.ts index 84800fb7..95b934c8 100644 --- a/src/application/utils/constants.ts +++ b/src/application/utils/constants.ts @@ -1,5 +1,6 @@ import lightniteAssetsHashes from '../constants/lightnite_asset_hash.json'; import blockstreamAssetHashes from '../constants/blockstream_asset_hash.json'; +import { Network } from '../../domain/network'; export const feeLevelToSatsPerByte: { [key: string]: number } = { '0': 0.1, @@ -7,7 +8,7 @@ export const feeLevelToSatsPerByte: { [key: string]: number } = { '100': 0.1, }; -export const taxiURL: Record = { +export const taxiURL: Record = { regtest: 'http://localhost:8000', liquid: 'https://grpc.liquid.taxi', }; diff --git a/src/application/utils/crypto.ts b/src/application/utils/crypto.ts index f9b90db4..8888401a 100644 --- a/src/application/utils/crypto.ts +++ b/src/application/utils/crypto.ts @@ -15,12 +15,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'); + } } export function sha256Hash(str: string): string { diff --git a/src/application/utils/restorer.ts b/src/application/utils/restorer.ts index e48f6818..c2742371 100644 --- a/src/application/utils/restorer.ts +++ b/src/application/utils/restorer.ts @@ -1,7 +1,25 @@ -import { StateRestorerOpts, Mnemonic, IdentityType, mnemonicRestorerFromState } from 'ldk'; -import { Address } from '../../domain/address'; +import { + StateRestorerOpts, + Mnemonic, + IdentityType, + mnemonicRestorerFromState, + MasterPublicKey, + masterPubKeyRestorerFromState, + CosignerMultisig, + MultisigWatchOnly, + XPub, + HDSignerMultisig, + restorerFromState, + AddressInterface, +} from 'ldk'; +import { Cosigner, MultisigWithCosigner } from '../../domain/cosigner'; +import { MasterBlindingKey } from '../../domain/master-blinding-key'; +import { MasterXPub } from '../../domain/master-extended-pub'; +import { Network } from '../../domain/network'; -export function getStateRestorerOptsFromAddresses(addresses: Address[]): StateRestorerOpts { +export function getStateRestorerOptsFromAddresses( + addresses: AddressInterface[] +): StateRestorerOpts { const derivationPaths = addresses.map((addr) => addr.derivationPath); const indexes = []; @@ -27,16 +45,100 @@ 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: string + chain: Network ): 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: Network +) { + const xpub = newMasterPublicKey(masterXPub, masterBlindingKey, network); + return masterPubKeyRestorerFromState(xpub)(restorerOpts); +} + +export function newMasterPublicKey( + masterXPub: MasterXPub, + masterBlindingKey: MasterBlindingKey, + network: Network +) { + return new MasterPublicKey({ + chain: network, + type: IdentityType.MasterPublicKey, + opts: { + masterPublicKey: masterXPub, + masterBlindingKey: masterBlindingKey, + }, + }); +} + +// create a Multisig Identity +// restore it using StateRestorerOpts +export function restoredMultisig( + signer: HDSignerMultisig, + cosigners: CosignerMultisig[], + requiredSignatures: number, + restorerOpts: StateRestorerOpts, + cosigner: Cosigner, + network: Network +) { + const multisigID = new MultisigWithCosigner( + { + chain: network, + type: IdentityType.Multisig, + opts: { + requiredSignatures, + signer, + cosigners, + }, + }, + cosigner + ); + + return restorerFromState(multisigID)(restorerOpts); +} + +// create a MultisigWatchOnly Identity +// restore it using StateRestorerOpts +export function restoredWatchOnlyMultisig( + signerXPub: XPub, + cosigners: CosignerMultisig[], + requiredSignatures: number, + restorerOpts: StateRestorerOpts, + network: Network +) { + const multisigID = newMultisigWatchOnly(network, requiredSignatures, cosigners, signerXPub); + return restorerFromState(multisigID)(restorerOpts); +} + +export function newMultisigWatchOnly( + network: Network, + 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..f520b91a 100644 --- a/src/application/utils/taxi.ts +++ b/src/application/utils/taxi.ts @@ -6,11 +6,11 @@ import { TopupWithAssetRequest, } from 'taxi-protobuf/generated/js/taxi_pb'; -export const fetchAssetsFromTaxi = async (taxiUrl: string): Promise => { +export async function fetchAssetsFromTaxi(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()); -}; +} export const fetchTopupFromTaxi = async ( taxiUrl: string, diff --git a/src/application/utils/transaction.ts b/src/application/utils/transaction.ts index 689b586e..44c0f080 100644 --- a/src/application/utils/transaction.ts +++ b/src/application/utils/transaction.ts @@ -1,4 +1,5 @@ import { + address, address as addrLDK, addToTx, BlindedOutputInterface, @@ -8,9 +9,9 @@ import { decodePset, getUnblindURLFromTx, greedyCoinSelector, + IdentityInterface, InputInterface, isBlindedOutputInterface, - Mnemonic, psetToUnsignedTx, RecipientInterface, TxInterface, @@ -18,7 +19,7 @@ import { UtxoInterface, } from 'ldk'; import { confidential, networks, payments, Psbt } from 'liquidjs-lib'; -import { blindingKeyFromAddress, isConfidentialAddress, networkFromString } from './address'; +import { isConfidentialAddress, networkFromString } from './address'; import { Transfer, TxDisplayInterface, TxStatusEnum, TxType } from '../../domain/transaction'; import { Topup } from 'taxi-protobuf/generated/js/taxi_pb'; import { lbtcAssetByNetwork } from './network'; @@ -27,46 +28,101 @@ import { fetchTopupFromTaxi } from './taxi'; import { taxiURL } from './constants'; 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; } +function inputBlindingDataMap( + pset: string, + utxos: UtxoInterface[] +): 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 (!utxo) { + throw new Error(`blindPSET error: utxo not found '${input.hash.reverse().toString('hex')}'`); + } + + if (utxo.unblindData) { + inputBlindingData.set(index, utxo.unblindData); + } + } + + return inputBlindingData; +} + +async function blindPset(psetBase64: string, utxos: UtxoInterface[], outputAddresses: string[]) { + const outputPubKeys = outPubKeysMap(psetBase64, outputAddresses); + const inputBlindingData = inputBlindingDataMap(psetBase64, utxos); + + return ( + await decodePset(psetBase64).blindOutputsByIndex(inputBlindingData, outputPubKeys) + ).toBase64(); +} + /** * 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, + selectedUtxos: UtxoInterface[], + identities: IdentityInterface[], recipientAddresses: string[] ): Promise { - const outputAddresses = (await mnemonic.getAddresses()).map((a) => a.confidentialAddress); + const outputAddresses: string[] = recipientAddresses; + for (const id of identities) { + outputAddresses.push(...(await id.getAddresses()).map((a) => a.confidentialAddress)); + } - const outputPubKeys = outPubKeysMap(psetBase64, outputAddresses.concat(recipientAddresses)); - const outputsToBlind = Array.from(outputPubKeys.keys()); + const blindedPset = await blindPset(psetBase64, selectedUtxos, outputAddresses); + const signedPset = await signPset(blindedPset, identities); - const blindedPset: string = await mnemonic.blindPset(psetBase64, outputsToBlind, outputPubKeys); + const decodedPset = decodePset(signedPset); + if (!decodedPset.validateSignaturesOfAllInputs()) { + throw new Error('PSET is not fully signed'); + } - const signedPset: string = await mnemonic.signPset(blindedPset); + return decodedPset.finalizeAllInputs().extractTransaction().toHex(); +} - const ptx = decodePset(signedPset); - if (!ptx.validateSignaturesOfAllInputs()) { - throw new Error('Transaction containes invalid signatures'); +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 { @@ -270,7 +326,11 @@ function getTransfers( }; for (const input of vin) { - if (!isBlindedOutputInterface(input.prevout) && walletScripts.includes(input.prevout.script)) { + if ( + input.prevout && + !isBlindedOutputInterface(input.prevout) && + walletScripts.includes(input.prevout.script) + ) { addToTransfers(-1 * input.prevout.value, input.prevout.asset); } } diff --git a/src/application/utils/wallet.ts b/src/application/utils/wallet.ts index ed17e065..a53308c7 100644 --- a/src/application/utils/wallet.ts +++ b/src/application/utils/wallet.ts @@ -1,6 +1,5 @@ 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 } from 'ldk'; import { Network } from '../../domain/network'; import { PasswordHash } from '../../domain/password-hash'; @@ -16,7 +15,6 @@ export interface WalletData { masterBlindingKey: MasterBlindingKey; passwordHash: PasswordHash; restorerOpts: StateRestorerOpts; - confidentialAddresses: Address[]; } export async function createWalletFromMnemonic( @@ -39,9 +37,7 @@ export async function createWalletFromMnemonic( 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) - ); + const addresses = await mnemonicIdentity.getAddresses(); return { restorerOpts: getStateRestorerOptsFromAddresses(addresses), @@ -49,6 +45,5 @@ export async function createWalletFromMnemonic( masterXPub, masterBlindingKey, passwordHash, - confidentialAddresses: addresses, }; } diff --git a/src/background/backend.ts b/src/background/backend.ts deleted file mode 100644 index f6d0d755..00000000 --- a/src/background/backend.ts +++ /dev/null @@ -1,312 +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, - isBlindedUtxo, - BlindingKeyGetter, - address, - fetchAndUnblindTxsGenerator, - fetchAndUnblindUtxosGenerator, - masterPubKeyRestorerFromEsplora, - MasterPublicKey, - masterPubKeyRestorerFromState, - utxoWithPrevout, -} from 'ldk'; -import { - fetchAssetsFromTaxi, - getStateRestorerOptsFromAddresses, - taxiURL, - toDisplayTransaction, - toStringOutpoint, -} from '../application/utils'; -import { - setDeepRestorerError, - setDeepRestorerIsLoading, - 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 { getExplorerURLSelector } from '../application/redux/selectors/app.selector'; -import { - RESET_APP, - RESET_CONNECT, - RESET_TAXI, - RESET_TXS, - RESET_WALLET, -} from '../application/redux/actions/action-types'; -import { flushTx } from '../application/redux/actions/connect'; - -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; - - const xpub = await getRestoredXPub(state); - const addrs = (await xpub.getAddresses()).reverse(); - if (addrs.length === 0) return; - - const explorer = getExplorerURLSelector(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) { - let utxo = utxoIterator?.value; - if (!isBlindedUtxo(utxo)) { - if (utxo.asset) { - const assets = getState().assets; - await fetchAssetInfos(utxo.asset, explorer, assets, dispatch).catch(console.error); - } - - if (!utxo.prevout) { - utxo = await utxoWithPrevout(utxo, explorer); - } - - 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}`); - } - }; -} - -/** - * 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; - // 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 = getExplorerURLSelector(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}`); - } - }; -} - -/** - * 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 = getExplorerURLSelector(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: any) { - dispatch(setDeepRestorerError(err.message || 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 76dc9c8f..a41b6ff8 100644 --- a/src/background/background-script.ts +++ b/src/background/background-script.ts @@ -52,7 +52,7 @@ 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' }); } @@ -71,7 +71,7 @@ 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 { 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/coinos/coinosBroker.ts b/src/content/coinos/coinosBroker.ts new file mode 100644 index 00000000..64315e20 --- /dev/null +++ b/src/content/coinos/coinosBroker.ts @@ -0,0 +1,163 @@ +import { Balance, Recipient } from 'marina-provider'; +import { setApproveParams } from '../../application/redux/actions/allowance'; +import { flushTx, setTx, setTxData } from '../../application/redux/actions/connect'; +import { + incrementAddressIndex, + incrementChangeAddressIndex, +} from '../../application/redux/actions/wallet'; +import { selectBalances } from '../../application/redux/selectors/balance.selector'; +import { + selectRestrictedAssetAccount, + selectTransactions, + selectUtxos, +} from '../../application/redux/selectors/wallet.selector'; +import { lbtcAssetByNetwork, sortRecipients } from '../../application/utils'; +import { RestrictedAssetAccountID } from '../../domain/account'; +import { assetGetterFromIAssets } from '../../domain/assets'; +import { AssetAmount } from '../../domain/connect'; +import { + MessageHandler, + newErrorResponseMessage, + newSuccessResponseMessage, + RequestMessage, +} from '../../domain/message'; +import CoinosProvider from '../../inject/coinOS/provider'; +import { SignTransactionPopupResponse } from '../../presentation/connect/sign-pset'; +import { SpendPopupResponse } from '../../presentation/connect/spend'; +import Broker, { BrokerOption } from '../broker'; +import MarinaBroker from '../marina/marinaBroker'; + +export default class CoinosBroker extends Broker { + private hostname: string; + + static async Start(hostname: string) { + const broker = new CoinosBroker(hostname, [await MarinaBroker.WithProxyStore()]); + broker.start(); + } + + private constructor(hostname: string, opts: BrokerOption[] = []) { + super(CoinosProvider.PROVIDER_NAME, opts); + this.hostname = hostname; + } + + start() { + super.start(this.messageHandler); + } + + private messageHandler: MessageHandler = async ({ id, name, params }: RequestMessage) => { + if (!this.store) throw new Error('proxy store is not set up in allowance broker'); + const state = this.store.getState(); + const successMsg = (data?: any) => newSuccessResponseMessage(id, data); + + try { + switch (name) { + case CoinosProvider.prototype.getCoins.name: { + const utxos = selectUtxos(RestrictedAssetAccountID)(state); + return successMsg(utxos); + } + + case CoinosProvider.prototype.getTransactions.name: { + const transactions = selectTransactions(RestrictedAssetAccountID)(state); + return successMsg(transactions); + } + + case CoinosProvider.prototype.getBalances.name: { + const balances = selectBalances(RestrictedAssetAccountID)(state); + const assetGetter = assetGetterFromIAssets(state.assets); + const balancesResult: Balance[] = []; + for (const [assetHash, amount] of Object.entries(balances)) { + balancesResult.push({ asset: assetGetter(assetHash), amount }); + } + return successMsg(balancesResult); + } + + case CoinosProvider.prototype.getNetwork.name: { + return successMsg(state.app.network); + } + + case CoinosProvider.prototype.getNextAddress.name: { + const account = selectRestrictedAssetAccount(state); + if (!account) throw RestrictedAccountNotDefined; + const id = await account.getWatchIdentity(); + const nextAddress = await id.getNextAddress(); + await this.store.dispatchAsync(incrementAddressIndex(account.getAccountID())); + return successMsg(nextAddress); + } + + case CoinosProvider.prototype.getNextChangeAddress.name: { + const account = selectRestrictedAssetAccount(state); + if (!account) throw RestrictedAccountNotDefined; + const id = await account.getWatchIdentity(); + const nextAddress = await id.getNextChangeAddress(); + await this.store.dispatchAsync(incrementChangeAddressIndex(account.getAccountID())); + return successMsg(nextAddress); + } + + case CoinosProvider.prototype.signTransaction.name: { + if (!params || params.length !== 1) { + throw new Error('Missing params'); + } + const [pset] = params; + await this.store.dispatchAsync(setTx(this.hostname, pset)); + const { accepted, signedPset } = + await this.openAndWaitPopup('sign-pset'); + + await this.store.dispatchAsync(flushTx()); + if (!accepted) throw new Error('User rejected the sign request'); + if (!signedPset) throw new Error('Something went wrong with tx signing'); + + return successMsg(signedPset); + } + + case CoinosProvider.prototype.sendTransaction.name: { + const [recipients, feeAssetHash] = params as [Recipient[], string | undefined]; + const lbtc = lbtcAssetByNetwork(state.app.network); + const feeAsset = feeAssetHash ? feeAssetHash : lbtc; + + if (![lbtc, ...state.taxi.taxiAssets].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) + ); + const { accepted, signedTxHex } = await this.openAndWaitPopup( + 'spend' + ); + + if (!accepted) throw new Error('the user rejected the create tx request'); + if (!signedTxHex) throw new Error('something went wrong with the tx crafting'); + return successMsg(signedTxHex); + } + + case CoinosProvider.prototype.approveSpend.name: { + if (!params || params.length !== 1) throw new Error('invalid params'); + const requestParams: AssetAmount[] = params[0].filter(isAssetAmount); + if (requestParams.length <= 0) throw new Error('invalid params'); + + await this.store.dispatchAsync(setApproveParams(requestParams)); + const result = await this.openAndWaitPopup('allow-coin'); + if (!result) { + throw new Error('user rejected the allowance'); + } + + return successMsg(result); + } + + default: + return newErrorResponseMessage(id, new Error('Method not implemented.')); + } + } catch (err) { + if (err instanceof Error) return newErrorResponseMessage(id, err); + else throw err; + } + }; +} + +function isAssetAmount(assetAmount: any): assetAmount is AssetAmount { + return assetAmount.asset && assetAmount.amount; +} + +const RestrictedAccountNotDefined = new Error('restricted account is not defined'); diff --git a/src/content/content-script.ts b/src/content/content-script.ts index 8becfd5f..b24f3914 100644 --- a/src/content/content-script.ts +++ b/src/content/content-script.ts @@ -1,6 +1,7 @@ import browser from 'webextension-polyfill'; -import MarinaBroker from './marinaBroker'; +import MarinaBroker from './marina/marinaBroker'; +import CoinosBroker from './coinos/coinosBroker'; // start the broker + inject the inject-script.js script startContentScript().catch(console.error); @@ -10,6 +11,8 @@ async function startContentScript() { if (doctypeCheck() && suffixCheck() && documentElementCheck()) { const currentHostname = window.location.hostname; await MarinaBroker.Start(currentHostname); + await CoinosBroker.Start(currentHostname); + injectScript(browser.runtime.getURL('inject-script.js')); } } diff --git a/src/content/marinaBroker.ts b/src/content/marina/marinaBroker.ts similarity index 80% rename from src/content/marinaBroker.ts rename to src/content/marina/marinaBroker.ts index 5c7d84ad..413d5b31 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,25 +22,24 @@ 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 } from 'ldk'; + selectMainAccount, + selectTransactions, + selectUtxos, +} from '../../application/redux/selectors/wallet.selector'; import { incrementAddressIndex, incrementChangeAddressIndex, -} from '../application/redux/actions/wallet'; -import { lbtcAssetByNetwork, sortRecipients } 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 { lbtcAssetByNetwork, sortRecipients } from '../../application/utils'; +import { selectBalances } from '../../application/redux/selectors/balance.selector'; +import { assetGetterFromIAssets } from '../../domain/assets'; import { Balance, Recipient } from 'marina-provider'; -import { SignTransactionPopupResponse } from '../presentation/connect/sign-pset'; -import { SpendPopupResponse } from '../presentation/connect/spend'; -import { SignMessagePopupResponse } from '../presentation/connect/sign-msg'; +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'; export default class MarinaBroker extends Broker { private static NotSetUpError = new Error('proxy store and/or cache are not set up'); @@ -48,7 +52,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(); @@ -122,23 +126,25 @@ export default class MarinaBroker extends Broker { case Marina.prototype.getAddresses.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const xpub = await selectMainAccount(state).getWatchIdentity(); return successMsg(await xpub.getAddresses()); } case Marina.prototype.getNextAddress.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const account = selectMainAccount(state); + const xpub = await account.getWatchIdentity(); const nextAddress = await xpub.getNextAddress(); - await this.store.dispatchAsync(incrementAddressIndex()); + await this.store.dispatchAsync(incrementAddressIndex(account.getAccountID())); return successMsg(nextAddress); } case Marina.prototype.getNextChangeAddress.name: { this.checkHostnameAuthorization(state); - const xpub = await getRestoredXPub(state); + const account = selectMainAccount(state); + const xpub = await account.getWatchIdentity(); const nextChangeAddress = await xpub.getNextChangeAddress(); - await this.store.dispatchAsync(incrementChangeAddressIndex()); + await this.store.dispatchAsync(incrementChangeAddressIndex(account.getAccountID())); return successMsg(nextChangeAddress); } @@ -200,19 +206,19 @@ 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); return successMsg(coins); } 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)) { @@ -223,7 +229,7 @@ export default class MarinaBroker extends Broker { case Marina.prototype.isReady.name: { try { - await getRestoredXPub(state); // check if Xpub is valid + await selectMainAccount(state).getWatchIdentity(); // check if Xpub is valid return successMsg(state.app.isOnboardingCompleted); } catch { // catch error = not ready @@ -246,9 +252,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 eaf748c2..7af695c8 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 { Network } from '../domain/network'; import { TxsHistory } from '../domain/transaction'; @@ -48,8 +49,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].utxosMap, + txsHistoryState: + state.wallet.unspentsAndTransactions[MainAccountID].transactions[state.app.network], 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..b787eb55 --- /dev/null +++ b/src/domain/account.ts @@ -0,0 +1,173 @@ +import { + DEFAULT_BASE_DERIVATION_PATH, + HDSignerMultisig, + IdentityInterface, + IdentityType, + MasterPublicKey, + Mnemonic, + Multisig, + MultisigWatchOnly, + StateRestorerOpts, + XPub, + multisigFromEsplora, + Restorer, + EsploraRestorerOpts, + masterPubKeyRestorerFromEsplora, + multisigWatchOnlyFromEsplora, +} from 'ldk'; +import { decrypt } from '../application/utils'; +import { + getStateRestorerOptsFromAddresses, + newMasterPublicKey, + newMultisigWatchOnly, + restoredMasterPublicKey, + restoredMnemonic, + restoredMultisig, + restoredWatchOnlyMultisig, +} from '../application/utils/restorer'; +import { MockedCosigner, MultisigWithCosigner } from './cosigner'; +import { EncryptedMnemonic } from './encrypted-mnemonic'; +import { MasterBlindingKey } from './master-blinding-key'; +import { MasterXPub } from './master-extended-pub'; +import { Network } from './network'; +import { CosignerExtraData } from './wallet'; + +export const MainAccountID = 'mainAccount'; +export const RestrictedAssetAccountID = 'restrictedAssetAccount'; + +export type AccountID = 'mainAccount' | 'restrictedAssetAccount'; + +/** + * 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): Promise; + getWatchIdentity(): Promise; + getDeepRestorer(): 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: StateRestorerOpts; + masterXPub: MasterXPub; + masterBlindingKey: MasterBlindingKey; +} + +export function createMnemonicAccount( + data: MnemonicAccountData, + network: Network +): MnemonicAccount { + return { + getAccountID: () => MainAccountID, + getSigningIdentity: (password: string) => + restoredMnemonic(decrypt(data.encryptedMnemonic, password), data.restorerOpts, network), + getWatchIdentity: () => + restoredMasterPublicKey(data.masterXPub, data.masterBlindingKey, data.restorerOpts, network), + getDeepRestorer: () => + masterPubKeyRestorerFromEsplora( + newMasterPublicKey(data.masterXPub, data.masterBlindingKey, network) + ), + }; +} + +// MultisigAccount aims to handle account with cosigner(s) +// use master extended public keys from cosigners and xpub derived from master private key (mnemonic) +export type MultisigAccount = Account; + +export interface MultisigAccountData { + baseDerivationPath: string; // we'll use the MainAccount in order to generate + signerXPub: XPub; + cosignerXPubs: XPub[]; + restorerOpts: StateRestorerOpts; + requiredSignature: number; + extraData: ExtraDataT; + network: Network; +} + +// create account data +// restore the Identity from esplora URL in order to compute the StateRestorerOpts +export async function create2of2MultisigAccountData( + signer: HDSignerMultisig, + cosignerXPub: XPub, + network: Network, + extraData: T, + explorerURL: string +): Promise> { + const multisigID = new Multisig({ + chain: network, + type: IdentityType.Multisig, + opts: { + requiredSignatures: 2, + signer, + cosigners: [cosignerXPub], + }, + }); + + const restoredFromExplorer = await multisigFromEsplora(multisigID)({ + esploraURL: explorerURL, + gapLimit: 30, + }); + const addresses = await restoredFromExplorer.getAddresses(); + const restorerOpts = getStateRestorerOptsFromAddresses(addresses); + + return { + baseDerivationPath: signer.baseDerivationPath || DEFAULT_BASE_DERIVATION_PATH, + signerXPub: multisigID.getXPub(), + cosignerXPubs: [cosignerXPub], + requiredSignature: 2, + extraData, + restorerOpts, + network, + }; +} + +export function createMultisigAccount( + encryptedMnemonic: EncryptedMnemonic, + data: MultisigAccountData +): MultisigAccount { + return { + getAccountID: () => RestrictedAssetAccountID, + getSigningIdentity: (password: string) => + restoredMultisig( + { + mnemonic: decrypt(encryptedMnemonic, password), + baseDerivationPath: data.baseDerivationPath, + }, + data.cosignerXPubs, + data.requiredSignature, + data.restorerOpts, + new MockedCosigner(data.network), + data.network + ), + getWatchIdentity: () => + restoredWatchOnlyMultisig( + data.signerXPub, + data.cosignerXPubs, + data.requiredSignature, + data.restorerOpts, + data.network + ), + getDeepRestorer: () => + multisigWatchOnlyFromEsplora( + newMultisigWatchOnly( + data.network, + data.requiredSignature, + data.cosignerXPubs, + data.signerXPub + ) + ), + }; +} diff --git a/src/domain/address.ts b/src/domain/address.ts index eb54ee0f..d2459bbd 100644 --- a/src/domain/address.ts +++ b/src/domain/address.ts @@ -12,25 +12,21 @@ export function createAddress( address: Address['value'], derivationPath?: Address['derivationPath'] ): Address { - try { - // Non Confidential - if (address.startsWith('ert') || address.startsWith('ex')) { - addressLDK.fromBech32(address); - return { - derivationPath: derivationPath, - value: address, - }; - } else { - // Confidential - const { blindingKey, unconfidentialAddress } = addressLDK.fromConfidential(address); - return { - value: address, - blindingKey: blindingKey, - derivationPath: derivationPath, - unconfidentialAddress: unconfidentialAddress, - }; - } - } catch (err) { - throw new Error(err.message); + // Non Confidential + if (address.startsWith('ert') || address.startsWith('ex')) { + addressLDK.fromBech32(address); + return { + derivationPath: derivationPath, + value: address, + }; + } else { + // Confidential + const { blindingKey, unconfidentialAddress } = addressLDK.fromConfidential(address); + return { + value: address, + blindingKey: blindingKey, + derivationPath: derivationPath, + unconfidentialAddress: unconfidentialAddress, + }; } } diff --git a/src/domain/assets.ts b/src/domain/assets.ts index d7b930d0..3c1bc929 100644 --- a/src/domain/assets.ts +++ b/src/domain/assets.ts @@ -5,7 +5,6 @@ export type Asset = { name: string; precision: number; ticker: string; - isRegtestAsset?: boolean; }; export type AssetGetter = (assetHash: string) => Asset & { assetHash: string }; diff --git a/src/domain/common.ts b/src/domain/common.ts index c8aa67c2..58a1208f 100644 --- a/src/domain/common.ts +++ b/src/domain/common.ts @@ -1,22 +1,22 @@ 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 { Action } from 'redux'; import { TaxiState } from '../application/redux/reducers/taxi-reducer'; import { IAssets } from './assets'; +import { AllowanceState } from '../application/redux/reducers/allowance-reducer'; export interface RootReducerState { app: IApp; assets: IAssets; onboarding: OnboardingState; transaction: TransactionState; - txsHistory: TxsHistoryByNetwork; - wallet: IWallet; + wallet: WalletState; connect: ConnectData; taxi: TaxiState; + allowance: AllowanceState; } export interface ActionWithPayload extends Action { diff --git a/src/domain/connect.ts b/src/domain/connect.ts index bddd1e6f..17476bb3 100644 --- a/src/domain/connect.ts +++ b/src/domain/connect.ts @@ -2,6 +2,11 @@ import { Network } from './network'; import { RecipientInterface } from 'ldk'; import { DataRecipient } from 'marina-provider'; +export interface AssetAmount { + asset: string; + amount: number; +} + export type ConnectData = { enabledSites: Record; hostnameSelected: string; @@ -16,6 +21,9 @@ export type ConnectData = { hostname?: string; message?: string; }; + allowance?: { + requestParam: AssetAmount[]; + }; }; export function newEmptyConnectData(): ConnectData { diff --git a/src/domain/cosigner.ts b/src/domain/cosigner.ts new file mode 100644 index 00000000..4c3f0235 --- /dev/null +++ b/src/domain/cosigner.ts @@ -0,0 +1,143 @@ +import { + decodePset, + DEFAULT_BASE_DERIVATION_PATH, + IdentityInterface, + IdentityOpts, + IdentityType, + Multisig, + multisigFromEsplora, + MultisigOpts, + XPub, +} from 'ldk'; +import { ECPair, Transaction } from 'liquidjs-lib'; +import { BlockstreamExplorerURLs, NigiriDefaultExplorerURLs } from './app'; +import { Network } from './network'; + +function decodeMultisigPath(path: string) { + const splitted = path.split('/'); + return { change: parseInt(splitted[0]), index: parseInt(splitted[1]) }; +} + +function addRedeemAndWitnessScriptsToInputs(pset: string, multisig: Multisig): string { + const decoded = decodePset(pset); + let inputIndex = 0; + for (const input of decoded.data.inputs) { + if (!input.witnessUtxo) continue; + const path = multisig.scriptToPath[input.witnessUtxo.script.toString('hex')]; + if (path) { + const { change, index } = decodeMultisigPath(path); + const p2ms = multisig.getMultisigAddress(change, index); + decoded.updateInput(inputIndex, { + witnessScript: Buffer.from(p2ms.witnessScript, 'hex'), + }); + } + + inputIndex++; + } + return decoded.toBase64(); +} + +export class MultisigWithCosigner extends Multisig implements IdentityInterface { + private cosigner: Cosigner; + + constructor(opts: IdentityOpts, cosigner: Cosigner) { + super(opts); + this.cosigner = cosigner; + } + + // this is used instead of super.signPset in case of the input must be signed with SIGHASH_NONE + SIGHASH_ANYONE_CAN_PAY (allowance) + private async signWithSighashNone(psetBase64: string): Promise { + const pset = decodePset(psetBase64); + const signInputPromises: Promise[] = []; + + for (let index = 0; index < pset.data.inputs.length; index++) { + const input = pset.data.inputs[index]; + if (input.witnessUtxo) { + const derivationPath = this.scriptToPath[input.witnessUtxo.script.toString('hex')]; + + if (derivationPath) { + // if there is an address generated for the input script: build the signing key pair. + const privKey = this.baseNode.derivePath(derivationPath).privateKey; + if (!privKey) throw new Error('signing private key is undefined'); + const signingKeyPair = ECPair.fromPrivateKey(privKey); + // add the promise to array + signInputPromises.push( + pset.signInputAsync(index, signingKeyPair, [ + Transaction.SIGHASH_NONE + Transaction.SIGHASH_ANYONECANPAY, + ]) + ); + } + } + } + // wait that all signing promise resolved + await Promise.all(signInputPromises); + // return the signed pset, base64 encoded. + return pset.toBase64(); + } + + async signPset(pset: string): Promise { + const toSign = addRedeemAndWitnessScriptsToInputs(pset, this); + const signed = await super.signPset(toSign); + return this.cosigner.signPset(signed, this.getXPub()); + } + + async allow(pset: string): Promise { + const toSign = addRedeemAndWitnessScriptsToInputs(pset, this); + return this.signWithSighashNone(toSign); + } +} + +export interface Cosigner { + xPub(): Promise; + signPset(pset: string, xpub: XPub): Promise; +} + +export class MockedCosigner implements Cosigner { + private mnemonic = + 'sponsor envelope waste fork indicate board survey tobacco laugh cover guitar layer'; + private network: Network; + private esploraURL: string; + + constructor(network: Network) { + this.network = network; + this.esploraURL = + network === 'liquid' + ? BlockstreamExplorerURLs.esploraURL + : NigiriDefaultExplorerURLs.esploraURL; + } + + xPub() { + return Promise.resolve( + 'xpub661MyMwAqRbcFgkcqS2dYiVoJLc9QEiVQLPcyG1pkVi2UTUSe8dCAjkUVqczLiamx4R9jrSj6GefRRFZyF9cfApymZm4WzazurfdaAYWqhb' + ); + } + + async signPset(pset: string, cosignerXPub: XPub) { + const multisigID = await multisigFromEsplora( + new Multisig({ + chain: this.network, + type: IdentityType.Multisig, + opts: { + requiredSignatures: 2, + cosigners: [cosignerXPub], + signer: { + mnemonic: this.mnemonic, + baseDerivationPath: DEFAULT_BASE_DERIVATION_PATH, + }, + }, + }) + )({ esploraURL: this.esploraURL, gapLimit: 20 }); + + await multisigID.getNextAddress(); + const signed = await multisigID.signPset(pset); + + if (!decodePset(signed).validateSignaturesOfAllInputs()) { + throw new Error('Mocked cosigner: not able to sign pset'); + } + return signed; + } + + allow(pset: string) { + return Promise.resolve(); + } +} diff --git a/src/domain/message.ts b/src/domain/message.ts index 47e2658d..12fd1928 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 @@ -14,10 +15,16 @@ export interface ResponseMessage { } // basically the name of the connect/* files -export type PopupName = 'enable' | 'sign-msg' | 'sign-pset' | 'spend'; +export type PopupName = 'enable' | 'sign-msg' | 'sign-pset' | 'spend' | 'allow-coin'; export function isPopupName(name: any): name is PopupName { - return name === 'enable' || name === 'sign-msg' || name === 'sign-pset' || name === 'spend'; + return ( + name === 'enable' || + name === 'sign-msg' || + name === 'sign-pset' || + name === 'spend' || + name === 'allow-coin' + ); } export function isResponseMessage(message: unknown): message is ResponseMessage { diff --git a/src/domain/transaction.ts b/src/domain/transaction.ts index 14d1ecfb..39c3b98d 100644 --- a/src/domain/transaction.ts +++ b/src/domain/transaction.ts @@ -1,8 +1,13 @@ -import { address, decodePset } from 'ldk'; +import { address, decodePset, UtxoInterface } from 'ldk'; import { Address } from './address'; import { IError } from './common'; import { Network } from './network'; +export interface UtxosAndTxsHistory { + utxosMap: Record; + transactions: TxsHistoryByNetwork; +} + export type TxsHistory = Record; export type TxsHistoryByNetwork = Record & Partial>; diff --git a/src/domain/wallet.ts b/src/domain/wallet.ts index 025a0782..a911cfee 100644 --- a/src/domain/wallet.ts +++ b/src/domain/wallet.ts @@ -1,18 +1,18 @@ -import { UtxoInterface, 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, + MultisigAccountData, + RestrictedAssetAccountID, +} from './account'; import { PasswordHash } from './password-hash'; +import { UtxosAndTxsHistory } from './transaction'; -export interface IWallet { - encryptedMnemonic: EncryptedMnemonic; - errors?: Record; - masterXPub: MasterXPub; - masterBlindingKey: MasterBlindingKey; +export interface WalletState { + [MainAccountID]: MnemonicAccountData; + [RestrictedAssetAccountID]?: MultisigAccountData; + unspentsAndTransactions: Record; passwordHash: PasswordHash; - utxoMap: Record; - restorerOpts: StateRestorerOpts; deepRestorer: { gapLimit: number; isLoading: boolean; @@ -20,3 +20,7 @@ export interface IWallet { }; isVerified: boolean; } + +export interface CosignerExtraData { + cosignerURL: string; +} diff --git a/src/inject/coinOS/provider.ts b/src/inject/coinOS/provider.ts new file mode 100644 index 00000000..128436f9 --- /dev/null +++ b/src/inject/coinOS/provider.ts @@ -0,0 +1,50 @@ +import { AddressInterface, UtxoInterface } from 'ldk'; +import { AssetAmount } from '../../domain/connect'; +import WindowProxy from '../proxy'; +import { Transaction, Balance, Recipient, TransactionHex, PsetBase64 } from 'marina-provider'; + +export default class CoinosProvider extends WindowProxy { + static PROVIDER_NAME = 'coinos'; + + constructor() { + super(CoinosProvider.PROVIDER_NAME); + } + + // returns the list of unspents owned by the restricted asset account + getCoins(): Promise { + return this.proxy(this.getCoins.name, []); + } + + getTransactions(): Promise { + return this.proxy(this.getTransactions.name, []); + } + + getBalances(): Promise { + return this.proxy(this.getBalances.name, []); + } + + getNextAddress(): Promise { + return this.proxy(this.getNextAddress.name, []); + } + + getNextChangeAddress(): Promise { + return this.proxy(this.getNextChangeAddress.name, []); + } + + getNetwork(): Promise<'liquid' | 'regtest' | 'testnet'> { + return this.proxy(this.getNetwork.name, []); + } + + sendTransaction(recipients: Recipient[], feeAsset?: string): Promise { + return this.proxy(this.sendTransaction.name, [recipients, feeAsset]); + } + + signTransaction(pset: PsetBase64): Promise { + return this.proxy(this.signTransaction.name, [pset]); + } + + // returns a signed pset with input = (txid, vout) (signed with SIGHASH_NONE) + approveSpend(toAllow: AssetAmount[]): Promise { + return this.proxy(this.approveSpend.name, [toAllow]); + } +} diff --git a/src/inject/inject-script.ts b/src/inject/inject-script.ts index 0940acf6..2ef686eb 100644 --- a/src/inject/inject-script.ts +++ b/src/inject/inject-script.ts @@ -1,8 +1,13 @@ // this is an inject script, "injected" in web pages // this script set up window.marina provider -import Marina from './marina'; +import CoinosProvider from './coinOS/provider'; +import Marina from './marina/provider'; const marina = new Marina(); (window as Record)[Marina.PROVIDER_NAME] = marina; window.dispatchEvent(new Event(`${Marina.PROVIDER_NAME}#initialized`)); + +const coinos = new CoinosProvider(); +(window as Record)[CoinosProvider.PROVIDER_NAME] = coinos; +window.dispatchEvent(new Event(`${CoinosProvider.PROVIDER_NAME}#initialized`)); 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 430bee28..886aaa10 100644 --- a/src/inject/marina.ts +++ b/src/inject/marina/provider.ts @@ -11,7 +11,7 @@ import { Recipient, } 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 50d9bc69..bfcab88a 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); @@ -31,6 +37,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 3e1618cc..69ed994e 100644 --- a/src/presentation/components/address-amount-form.tsx +++ b/src/presentation/components/address-amount-form.tsx @@ -1,5 +1,4 @@ import { FormikProps, withFormik } from 'formik'; -import { masterPubKeyRestorerFromState, MasterPublicKey, StateRestorerOpts } from 'ldk'; import { RouteComponentProps } from 'react-router'; import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; import cx from 'classnames'; @@ -14,6 +13,7 @@ import { TransactionState } from '../../application/redux/reducers/transaction-r import { IAssets } from '../../domain/assets'; import { Network } from '../../domain/network'; import { incrementChangeAddressIndex } from '../../application/redux/actions/wallet'; +import { Account } from '../../domain/account'; interface AddressAmountFormValues { address: string; @@ -28,11 +28,10 @@ interface AddressAmountFormProps { dispatch: ProxyStoreDispatch; assetPrecision: number; history: RouteComponentProps['history']; - pubKey: MasterPublicKey; - restorerOpts: StateRestorerOpts; transaction: TransactionState; assets: IAssets; network: Network; + account: Account; } const AddressAmountForm = (props: FormikProps) => { @@ -157,14 +156,14 @@ const AddressAmountEnhancedForm = withFormik { - const masterPubKey = await masterPubKeyRestorerFromState(props.pubKey)(props.restorerOpts); + const masterPubKey = await props.account.getWatchIdentity(); 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())); // persist address in wallet await props .dispatch( diff --git a/src/presentation/components/shell-popup.tsx b/src/presentation/components/shell-popup.tsx index c119a541..26e82787 100644 --- a/src/presentation/components/shell-popup.tsx +++ b/src/presentation/components/shell-popup.tsx @@ -4,9 +4,12 @@ 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 { flushPendingTx } from '../../application/redux/actions/transaction'; +import { + selectAllAccountsIDs, + selectDeepRestorerIsLoading, +} from '../../application/redux/selectors/wallet.selector'; +import { updateTaskAction } from '../../application/redux/actions/updater'; interface Props { btnDisabled?: boolean; @@ -31,9 +34,8 @@ const ShellPopUp: React.FC = ({ const history = useHistory(); const dispatch = useDispatch(); - const deepRestorerLoading = useSelector( - (state: RootReducerState) => state.wallet.deepRestorer.isLoading - ); + const allAccountsIds = useSelector(selectAllAccountsIDs); + const deepRestorerLoading = useSelector(selectDeepRestorerIsLoading); // Menu modal const [isMenuModalOpen, showMenuModal] = useState(false); const openMenuModal = () => showMenuModal(true); @@ -42,11 +44,11 @@ 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); + await Promise.all(allAccountsIds.map(updateTaskAction).map(dispatch)); + } else { + history.push(DEFAULT_ROUTE); } await dispatch(flushPendingTx()); - history.push(DEFAULT_ROUTE); }; const handleBackBtn = () => { if (backBtnCb) { diff --git a/src/presentation/connect/allow-coin.tsx b/src/presentation/connect/allow-coin.tsx new file mode 100644 index 00000000..1174852f --- /dev/null +++ b/src/presentation/connect/allow-coin.tsx @@ -0,0 +1,141 @@ +import React, { useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + connectWithConnectData, + WithConnectDataProps, +} from '../../application/redux/containers/with-connect-data.container'; +import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; +import { RootReducerState } from '../../domain/common'; +import Button from '../components/button'; +import ShellConnectPopup from '../components/shell-connect-popup'; +import PopupWindowProxy from './popupWindowProxy'; +import { debounce } from 'lodash'; +import { MultisigWithCosigner } from '../../domain/cosigner'; +import { greedyCoinSelector, UtxoInterface } from 'ldk'; +import { Network } from '../../domain/network'; +import { Psbt, Transaction } from 'liquidjs-lib'; +import { networkFromString } from '../../application/utils'; +import { + selectRestrictedAssetAccount, + selectUtxos, +} from '../../application/redux/selectors/wallet.selector'; +import { RestrictedAssetAccountID } from '../../domain/account'; +import ModalUnlock from '../components/modal-unlock'; +import { extractErrorMessage } from '../utils/error'; +import { addAllowedCoin } from '../../application/redux/actions/allowance'; +import { assetGetterFromIAssets } from '../../domain/assets'; +import { fromSatoshi } from '../utils'; + +/** + * All a set of utxos to be spent later. + * use SIGHASH_NONE to sign the inputs + * @param identity the signer + * @param utxos the coins to approve + */ +async function createAllowCoinsPset( + identity: MultisigWithCosigner, + utxos: UtxoInterface[], + network: Network +): Promise { + const pset = new Psbt({ network: networkFromString(network) }); + + for (const utxo of utxos) { + pset.addInput({ + hash: utxo.txid, + index: utxo.vout, + witnessUtxo: utxo.prevout, + sighashType: Transaction.SIGHASH_NONE + Transaction.SIGHASH_ANYONECANPAY, + }); + } + + return identity.allow(pset.toBase64()); +} + +const AllowCoinView: React.FC = ({ connectData }) => { + const network = useSelector((state: RootReducerState) => state.app.network); + const restrictedAssetAccount = useSelector(selectRestrictedAssetAccount); + const utxos = useSelector(selectUtxos(RestrictedAssetAccountID)); + const getAsset = useSelector((state: RootReducerState) => assetGetterFromIAssets(state.assets)); + + const [unlock, setUnlock] = useState(false); + const [error, setError] = useState(); + const dispatch = useDispatch(); + const popupWindowProxy = new PopupWindowProxy(); + + const handleReject = async () => { + await popupWindowProxy.sendResponse({ data: '' }); + window.close(); + }; + + const openPasswordModal = () => { + setError(undefined); + setUnlock(true); + }; + + const handleAllow = async (password: string) => { + if (!connectData.allowance?.requestParam) throw new Error('no coin to allow'); + if (!restrictedAssetAccount) + throw new Error('multisig account is undefined, u maybe need to pair with a cosigner'); + + try { + const id = await restrictedAssetAccount.getSigningIdentity(password); + const changeAddress = (await id.getNextChangeAddress()).confidentialAddress; + + const { selectedUtxos } = greedyCoinSelector()( + utxos, + connectData.allowance.requestParam.map((p) => ({ ...p, value: p.amount, address: '' })), + () => changeAddress + ); + + const allowPset = await createAllowCoinsPset(id, selectedUtxos, network); + + await Promise.all(selectedUtxos.map(addAllowedCoin).map(dispatch)); + await popupWindowProxy.sendResponse({ data: allowPset }); + + window.close(); + } catch (err) { + setError(extractErrorMessage(err)); + } finally { + setUnlock(false); + } + }; + + const debouncedHandleAllow = useRef( + debounce(handleAllow, 2000, { leading: true, trailing: false }) + ).current; + + return ( + +

Allow

+ +

Allow website to spend:

+
+ {error &&

{error}

} + {connectData.allowance?.requestParam && + connectData.allowance.requestParam.map(({ asset, amount }, index) => ( +

{`${fromSatoshi(amount, getAsset(asset).precision)} ${ + getAsset(asset).ticker + }`}

+ ))} +
+
+ + +
+ setUnlock(false)} + handleUnlock={debouncedHandleAllow} + /> +
+ ); +}; + +export default connectWithConnectData(AllowCoinView); 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 7d35979a..392ed5e3 100644 --- a/src/presentation/connect/sign-msg.tsx +++ b/src/presentation/connect/sign-msg.tsx @@ -39,7 +39,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 57ab30b9..a4369290 100644 --- a/src/presentation/connect/sign-pset.tsx +++ b/src/presentation/connect/sign-pset.tsx @@ -8,10 +8,9 @@ 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'; export interface SignTransactionPopupResponse { accepted: boolean; @@ -24,11 +23,7 @@ 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 handleModalUnlockClose = () => showUnlockModal(false); const handleUnlockModalOpen = () => showUnlockModal(true); @@ -52,12 +47,9 @@ 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 signedPset = await mnemo.signPset(tx.pset); + const identities = await Promise.all(accounts.map((a) => a.getSigningIdentity(password))); + const signedPset = await signPset(tx.pset, identities); + await sendResponseMessage(true, signedPset); window.close(); @@ -73,7 +65,6 @@ const ConnectSignTransaction: React.FC = ({ connectData }) debounce(signTx, 2000, { leading: true, trailing: false }) ).current; - console.log(connectData.tx?.pset); return ( = ({ 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 mainAccount = useSelector(selectMainAccount); + const restrictedAssetAccount = useSelector(selectRestrictedAssetAccount); + const network = useSelector((state: RootReducerState) => state.app.network); + const coins = useSelector(selectUtxos(MainAccountID, RestrictedAssetAccountID)); const dispatch = useDispatch(); @@ -68,19 +74,27 @@ 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, - network + const assets = assetsSet( + connectData.tx?.recipients, + connectData.tx.feeAssetHash ?? lbtcAssetByNetwork(network) ); + + const { getter, changeAddresses } = await changeAddressGetter(mainAccount, assets, dispatch); + + const accounts: Account[] = restrictedAssetAccount + ? [mainAccount, restrictedAssetAccount] + : [mainAccount]; + const identities = await Promise.all(accounts.map((a) => a.getSigningIdentity(password))); const signedTxHex = await makeTransaction( - mnemonicIdentity, + identities, coins, connectData.tx, network, - dispatch + getter, + changeAddresses ); await sendResponseMessage(true, signedTxHex); @@ -159,36 +173,49 @@ const ConnectSpend: React.FC = ({ connectData }) => { export default connectWithConnectData(ConnectSpend); -async function makeTransaction( - mnemonic: Mnemonic, - coins: UtxoInterface[], - connectDataTx: ConnectData['tx'], - network: Network, - 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 +): Promise<{ getter: ChangeAddressFromAssetGetter; changeAddresses: string[] }> { const changeAddresses: Record = {}; const persisted: Record = {}; + const id = await account.getWatchIdentity(); 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 undefined; // 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)) return undefined; // will throw an error in coin selector + if (!persisted[asset]) { + dispatch(incrementChangeAddressIndex(account.getAccountID())).catch(console.error); + persisted[asset] = true; + } + return changeAddresses[asset].confidentialAddress; + }, + changeAddresses: Object.values(changeAddresses).map((a) => a.confidentialAddress), }; +} + +async function makeTransaction( + identities: IdentityInterface[], + coins: UtxoInterface[], + connectDataTx: ConnectData['tx'], + network: Network, + 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, @@ -200,11 +227,10 @@ 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).concat(changeAddresses) ); return txHex; diff --git a/src/presentation/cosigner/pair-success.tsx b/src/presentation/cosigner/pair-success.tsx new file mode 100644 index 00000000..c39dc0d2 --- /dev/null +++ b/src/presentation/cosigner/pair-success.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Shell from '../components/shell'; + +const PairSuccess: React.FC = () => { + return ( + +

New account successfully restored and add to Marina! You can close this page.

+ mermaid +
+ ); +}; + +export default PairSuccess; diff --git a/src/presentation/cosigner/pair.tsx b/src/presentation/cosigner/pair.tsx new file mode 100644 index 00000000..644a03a7 --- /dev/null +++ b/src/presentation/cosigner/pair.tsx @@ -0,0 +1,123 @@ +import { Field, Form, FormikProps, withFormik } from 'formik'; +import React from 'react'; +import Shell from '../components/shell'; +import * as Yup from 'yup'; +import Button from '../components/button'; +import { create2of2MultisigAccountData } from '../../domain/account'; +import { CosignerExtraData } from '../../domain/wallet'; +import { decrypt } from '../../application/utils'; +import { EncryptedMnemonic } from '../../domain/encrypted-mnemonic'; +import { Cosigner, MockedCosigner } from '../../domain/cosigner'; +import { Network } from '../../domain/network'; +import { useDispatch } from 'react-redux'; +import { ProxyStoreDispatch } from '../../application/redux/proxyStore'; +import { setRestrictedAssetData } from '../../application/redux/actions/wallet'; +import { DEFAULT_BASE_DERIVATION_PATH } from 'ldk'; +import { useHistory } from 'react-router'; +import { PAIR_SUCCESS_COSIGNER_ROUTE } from '../routes/constants'; + +interface OptInFormProps { + onSubmit: (values: OptInFormValues) => Promise; +} + +interface OptInFormValues { + password: string; + derivationPath: string; +} + +const optInForm = (props: FormikProps) => { + const { touched, errors, isSubmitting } = props; + + const touchedAndError = (value: keyof OptInFormValues) => touched[value] && errors[value]; + + return ( +
+

Password

+ + {touchedAndError('password') &&
{errors.password}
} + +

Derivation path

+ + + {touchedAndError('derivationPath') &&
{errors.derivationPath}
} + + + ); +}; + +const OptInFormikForm = withFormik({ + validationSchema: Yup.object().shape({ + password: Yup.string().required(), + derivationPath: Yup.string() + .required() + .matches(/^(m\/)?(\d+'?\/)*\d+'?$/, () => 'invalid BIP32 derivation path'), + }), + + mapPropsToValues: () => ({ + cosignerURL: '', + derivationPath: DEFAULT_BASE_DERIVATION_PATH, + password: '', + }), + + handleSubmit: async (values, { props }) => { + await props.onSubmit(values); + }, + + displayName: 'OptInForm', +})(optInForm); + +export interface PairCosignerProps { + encryptedMnemonic: EncryptedMnemonic; + network: Network; + explorerURL: string; +} + +const PairCosignerView: React.FC = ({ + encryptedMnemonic, + network, + explorerURL, +}) => { + const dispatch = useDispatch(); + const history = useHistory(); + + const onSubmit = async (values: OptInFormValues) => { + const walletSignerData = { + mnemonic: decrypt(encryptedMnemonic, values.password), + baseDerivationPath: values.derivationPath, + }; + + // cosigner should be created from values.cosignerURL + const cosigner: Cosigner = new MockedCosigner(network); + + const multisigAccountData = await create2of2MultisigAccountData( + walletSignerData, + await cosigner.xPub(), + network, + { cosignerURL: 'http://cosigner.URL' }, + explorerURL + ); + + await dispatch(setRestrictedAssetData(multisigAccountData)); + history.push(PAIR_SUCCESS_COSIGNER_ROUTE); + }; + + return ( + +

Add a new 2-of-2 Account

+ +
+ ); +}; + +export default PairCosignerView; diff --git a/src/presentation/onboarding/backup-unlock/index.tsx b/src/presentation/onboarding/backup-unlock/index.tsx index d0d94b82..2792ccfe 100644 --- a/src/presentation/onboarding/backup-unlock/index.tsx +++ b/src/presentation/onboarding/backup-unlock/index.tsx @@ -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 0e09e246..24ac1827 100644 --- a/src/presentation/onboarding/end-of-flow/index.tsx +++ b/src/presentation/onboarding/end-of-flow/index.tsx @@ -1,14 +1,18 @@ +import { DEFAULT_BASE_DERIVATION_PATH } 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 { setRestrictedAssetData, setWalletData } from '../../../application/redux/actions/wallet'; import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; import { setUpPopup } from '../../../application/utils/popup'; import { createWalletFromMnemonic } from '../../../application/utils/wallet'; +import { create2of2MultisigAccountData } from '../../../domain/account'; +import { MockedCosigner } from '../../../domain/cosigner'; import { createMnemonic } from '../../../domain/mnemonic'; import { Network } from '../../../domain/network'; import { createPassword } from '../../../domain/password'; +import { CosignerExtraData } from '../../../domain/wallet'; import Button from '../../components/button'; import MermaidLoader from '../../components/mermaid-loader'; import Shell from '../../components/shell'; @@ -18,6 +22,7 @@ export interface EndOfFlowProps { mnemonic: string; password: string; isFromPopupFlow: boolean; + needSecurityAccount: boolean; network: Network; explorerURL: string; hasMnemonicRegistered: boolean; @@ -30,6 +35,7 @@ const EndOfFlowOnboardingView: React.FC = ({ network, explorerURL, hasMnemonicRegistered, + needSecurityAccount, }) => { const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(true); @@ -39,6 +45,7 @@ const EndOfFlowOnboardingView: React.FC = ({ try { setIsLoading(true); setErrorMsg(undefined); + if (!isFromPopupFlow) { const walletData = await createWalletFromMnemonic( createPassword(password), @@ -50,12 +57,26 @@ const EndOfFlowOnboardingView: React.FC = ({ if (hasMnemonicRegistered) { await dispatch(reset()); } - await dispatch(setWalletData(walletData)); + await dispatch(setWalletData(walletData)); // Startup alarms to fetch utxos & set the popup page await setUpPopup(); await dispatch(onboardingCompleted()); + + if (needSecurityAccount) { + const cosigner = new MockedCosigner(network); + const multisigAccountData = await create2of2MultisigAccountData( + { mnemonic, baseDerivationPath: DEFAULT_BASE_DERIVATION_PATH }, + await cosigner.xPub(), + network, + { cosignerURL: 'http://cosigner.URL' }, + explorerURL + ); + + await dispatch(setRestrictedAssetData(multisigAccountData)); + } } + await dispatch(flushOnboarding()); } catch (err: unknown) { console.error(err); diff --git a/src/presentation/onboarding/onboarding-form.tsx b/src/presentation/onboarding/onboarding-form.tsx new file mode 100644 index 00000000..337a3310 --- /dev/null +++ b/src/presentation/onboarding/onboarding-form.tsx @@ -0,0 +1,165 @@ +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.makeSecurityAccount && touched.makeSecurityAccount && ( +

{errors.makeSecurityAccount}

+ )} +
+ +
+ + {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/wallet-create/index.tsx b/src/presentation/onboarding/wallet-create/index.tsx index e6d62051..b7bd448c 100644 --- a/src/presentation/onboarding/wallet-create/index.tsx +++ b/src/presentation/onboarding/wallet-create/index.tsx @@ -1,166 +1,37 @@ +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'; - -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); +import { ProxyStoreDispatch } from '../../../application/redux/proxyStore'; +import Shell from '../../components/shell'; +import { INITIALIZE_SEED_PHRASE_ROUTE } from '../../routes/constants'; +import OnboardingForm from '../onboarding-form'; -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 af82ccd9..93329409 100644 --- a/src/presentation/onboarding/wallet-restore/index.tsx +++ b/src/presentation/onboarding/wallet-restore/index.tsx @@ -1,170 +1,41 @@ -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 { 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 ( -
-
-