/ common / features / wallet / sagas.ts
sagas.ts
  1  import { SagaIterator, delay, Task } from 'redux-saga';
  2  import { apply, call, fork, put, select, takeEvery, take, cancel } from 'redux-saga/effects';
  3  
  4  import { translateRaw } from 'translations';
  5  import { INode } from 'libs/nodes/INode';
  6  import { Wei } from 'libs/units';
  7  import { Token } from 'types/network';
  8  import {
  9    IWallet,
 10    MnemonicWallet,
 11    getPrivKeyWallet,
 12    getKeystoreWallet,
 13    determineKeystoreType,
 14    KeystoreTypes,
 15    getUtcWallet,
 16    signWrapper,
 17    WalletConfig
 18  } from 'libs/wallet';
 19  import { loadWalletConfig, saveWalletConfig } from 'utils/localStorage';
 20  import { AppState } from 'features/reducers';
 21  import * as derivedSelectors from 'features/selectors';
 22  import * as configMetaTypes from 'features/config/meta/types';
 23  import * as configMetaSelectors from 'features/config/meta/selectors';
 24  import * as configNodesSelectors from 'features/config/nodes/selectors';
 25  import * as configSelectors from 'features/config/selectors';
 26  import { notificationsActions } from 'features/notifications';
 27  import { customTokensTypes, customTokensSelectors } from 'features/customTokens';
 28  import * as types from './types';
 29  import * as actions from './actions';
 30  import * as selectors from './selectors';
 31  
 32  export function* getTokenBalancesSaga(wallet: IWallet, tokens: Token[]) {
 33    const node: INode = yield select(configNodesSelectors.getNodeLib);
 34    const address: string = yield apply(wallet, wallet.getAddressString);
 35    const tokenBalances: types.TokenBalance[] = yield apply(node, node.getTokenBalances, [
 36      address,
 37      tokens
 38    ]);
 39    return tokens.reduce<{ [TokenSymbol: string]: types.TokenBalance }>((acc, t, i) => {
 40      acc[t.symbol] = tokenBalances[i];
 41      return acc;
 42    }, {});
 43  }
 44  
 45  // Return an array of the tokens that meet any of the following conditions:
 46  //  1. Non-zero balance
 47  //  2. It was in the previous wallet's config
 48  //  3. It's a custom token that the user added
 49  export function* filterScannedTokenBalances(wallet: IWallet, balances: types.TokenBalanceLookup) {
 50    const customTokens: AppState['customTokens'] = yield select(
 51      customTokensSelectors.getCustomTokens
 52    );
 53    const oldConfig: WalletConfig = yield call(loadWalletConfig, wallet);
 54    return Object.keys(balances).filter(symbol => {
 55      if (balances[symbol] && !balances[symbol].balance.isZero()) {
 56        return true;
 57      }
 58      if (oldConfig.tokens && oldConfig.tokens.includes(symbol)) {
 59        return true;
 60      }
 61      if (customTokens.find(token => token.symbol === symbol)) {
 62        return true;
 63      }
 64    });
 65  }
 66  
 67  export function* updateAccountBalance(): SagaIterator {
 68    try {
 69      const isOffline = yield select(configMetaSelectors.getOffline);
 70      if (isOffline) {
 71        return;
 72      }
 73  
 74      yield put(actions.setBalancePending());
 75      const wallet: null | IWallet = yield select(selectors.getWalletInst);
 76      if (!wallet) {
 77        return;
 78      }
 79      const node: INode = yield select(configNodesSelectors.getNodeLib);
 80      const address: string = yield apply(wallet, wallet.getAddressString);
 81      // network request
 82      const balance: Wei = yield apply(node, node.getBalance, [address]);
 83      yield put(actions.setBalanceFullfilled(balance));
 84    } catch (error) {
 85      yield put(actions.setBalanceRejected());
 86    }
 87  }
 88  
 89  export function* retryTokenBalances(): SagaIterator {
 90    const tokens: types.MergedToken[] = yield select(derivedSelectors.getWalletConfigTokens);
 91    if (tokens && tokens.length) {
 92      yield call(updateTokenBalances);
 93    } else {
 94      const wallet: null | IWallet = yield select(selectors.getWalletInst);
 95      if (wallet) {
 96        yield call(scanWalletForTokensSaga, wallet);
 97      }
 98    }
 99  }
100  
101  export function* updateTokenBalances(): SagaIterator {
102    try {
103      const isOffline = yield select(configMetaSelectors.getOffline);
104      if (isOffline) {
105        return;
106      }
107  
108      const wallet: null | IWallet = yield select(selectors.getWalletInst);
109      const tokens: types.MergedToken[] = yield select(derivedSelectors.getWalletConfigTokens);
110      if (!wallet || !tokens.length) {
111        return;
112      }
113      yield put(actions.setTokenBalancesPending());
114      const tokenBalances: types.TokenBalanceLookup = yield call(
115        getTokenBalancesSaga,
116        wallet,
117        tokens
118      );
119      yield put(actions.setTokenBalancesFulfilled(tokenBalances));
120    } catch (error) {
121      console.error('Failed to get token balances', error);
122      yield put(actions.setTokenBalancesRejected());
123    }
124  }
125  
126  export function* updateTokenBalance(action: types.SetTokenBalancePendingAction): SagaIterator {
127    try {
128      const isOffline = yield select(configMetaSelectors.getOffline);
129      if (isOffline) {
130        return;
131      }
132  
133      const wallet: null | IWallet = yield select(selectors.getWalletInst);
134      const { tokenSymbol } = action.payload;
135      const allTokens: Token[] = yield select(configSelectors.getAllTokens);
136      const token = allTokens.find(t => t.symbol === tokenSymbol);
137  
138      if (!wallet) {
139        return;
140      }
141  
142      if (!token) {
143        throw Error('Token not found');
144      }
145  
146      const tokenBalances: types.TokenBalanceLookup = yield call(getTokenBalancesSaga, wallet, [
147        token
148      ]);
149  
150      yield put(actions.setTokenBalanceFulfilled(tokenBalances));
151    } catch (error) {
152      console.error('Failed to get token balance', error);
153      yield put(actions.setTokenBalanceRejected());
154    }
155  }
156  
157  export function* handleScanWalletAction(action: types.ScanWalletForTokensAction): SagaIterator {
158    yield call(scanWalletForTokensSaga, action.payload);
159  }
160  
161  export function* scanWalletForTokensSaga(wallet: IWallet): SagaIterator {
162    try {
163      const isOffline = yield select(configMetaSelectors.getOffline);
164      if (isOffline) {
165        return;
166      }
167  
168      const tokens: types.MergedToken[] = yield select(derivedSelectors.getTokens);
169      yield put(actions.setTokenBalancesPending());
170  
171      // Fetch all token balances, save ones we want to the config
172      const balances: types.TokenBalanceLookup = yield call(getTokenBalancesSaga, wallet, tokens);
173      const tokensToSave: string[] = yield call(filterScannedTokenBalances, wallet, balances);
174      const config: WalletConfig = yield call(saveWalletConfig, wallet, { tokens: tokensToSave });
175      yield put(actions.setWalletConfig(config));
176  
177      yield put(actions.setTokenBalancesFulfilled(balances));
178    } catch (err) {
179      console.error('Failed to scan for tokens', err);
180      yield put(actions.setTokenBalancesRejected());
181    }
182  }
183  
184  export function* handleSetWalletTokens(action: types.SetWalletTokensAction): SagaIterator {
185    const wallet: null | IWallet = yield select(selectors.getWalletInst);
186    if (!wallet) {
187      return;
188    }
189  
190    const config: WalletConfig = yield call(saveWalletConfig, wallet, { tokens: action.payload });
191    yield put(actions.setWalletConfig(config));
192  }
193  
194  export function* updateBalances(): SagaIterator {
195    const updateAccount = yield fork(updateAccountBalance);
196    const updateToken = yield fork(updateTokenBalances);
197  
198    yield take(types.WalletActions.SET);
199    yield cancel(updateAccount);
200    yield cancel(updateToken);
201  }
202  
203  export function* handleNewWallet(): SagaIterator {
204    yield call(updateWalletConfig);
205    yield fork(updateBalances);
206  }
207  
208  export function* updateWalletConfig(): SagaIterator {
209    const wallet: null | IWallet = yield select(selectors.getWalletInst);
210    if (!wallet) {
211      return;
212    }
213    const config: WalletConfig = yield call(loadWalletConfig, wallet);
214    yield put(actions.setWalletConfig(config));
215  }
216  
217  export function* unlockPrivateKeySaga(action: types.UnlockPrivateKeyAction): SagaIterator {
218    let wallet: IWallet | null = null;
219    const { key, password } = action.payload;
220  
221    try {
222      wallet = getPrivKeyWallet(key, password);
223    } catch (e) {
224      yield put(notificationsActions.showNotification('danger', translateRaw('INVALID_PKEY')));
225      return;
226    }
227    yield put(actions.setWallet(wallet));
228  }
229  
230  export function* startLoadingSpinner(): SagaIterator {
231    yield call(delay, 400);
232    yield put(actions.setWalletPending(true));
233  }
234  
235  export function* stopLoadingSpinner(loadingFork: Task | null): SagaIterator {
236    if (loadingFork !== null && loadingFork !== undefined) {
237      yield cancel(loadingFork);
238    }
239    yield put(actions.setWalletPending(false));
240  }
241  
242  export function* unlockKeystoreSaga(action: types.UnlockKeystoreAction): SagaIterator {
243    const { file, password } = action.payload;
244    let wallet: null | IWallet = null;
245    let spinnerTask: null | Task = null;
246    try {
247      if (determineKeystoreType(file) === KeystoreTypes.utc) {
248        spinnerTask = yield fork(startLoadingSpinner);
249        wallet = signWrapper(yield call(getUtcWallet, file, password));
250      } else {
251        wallet = getKeystoreWallet(file, password);
252      }
253    } catch (e) {
254      yield call(stopLoadingSpinner, spinnerTask);
255      if (
256        password === '' &&
257        e.message === 'Private key does not satisfy the curve requirements (ie. it is invalid)'
258      ) {
259        yield put(actions.setPasswordPrompt());
260      } else {
261        yield put(notificationsActions.showNotification('danger', translateRaw('ERROR_6')));
262      }
263      return;
264    }
265  
266    // TODO: provide a more descriptive error than the two 'ERROR_6' (invalid pass) messages above
267    yield call(stopLoadingSpinner, spinnerTask);
268    yield put(actions.setWallet(wallet));
269  }
270  
271  export function* unlockMnemonicSaga(action: types.UnlockMnemonicAction): SagaIterator {
272    let wallet;
273    const { phrase, pass, path, address } = action.payload;
274  
275    try {
276      wallet = MnemonicWallet(phrase, pass, path, address);
277    } catch (err) {
278      // TODO: use better error than 'ERROR_14' (wallet not found)
279      yield put(notificationsActions.showNotification('danger', translateRaw('ERROR_14')));
280      return;
281    }
282  
283    yield put(actions.setWallet(wallet));
284  }
285  
286  export function* handleCustomTokenAdd(
287    action: customTokensTypes.AddCustomTokenAction
288  ): SagaIterator {
289    // Add the custom token to our current wallet's config
290    const wallet: null | IWallet = yield select(selectors.getWalletInst);
291    if (!wallet) {
292      return;
293    }
294    const oldConfig: WalletConfig = yield call(loadWalletConfig, wallet);
295    const config: WalletConfig = yield call(saveWalletConfig, wallet, {
296      tokens: [...(oldConfig.tokens || []), action.payload.symbol]
297    });
298    yield put(actions.setWalletConfig(config));
299  
300    // Update token balances
301    yield fork(updateTokenBalances);
302  }
303  
304  export function* walletSaga(): SagaIterator {
305    yield [
306      takeEvery(types.WalletActions.UNLOCK_PRIVATE_KEY, unlockPrivateKeySaga),
307      takeEvery(types.WalletActions.UNLOCK_KEYSTORE, unlockKeystoreSaga),
308      takeEvery(types.WalletActions.UNLOCK_MNEMONIC, unlockMnemonicSaga),
309      takeEvery(types.WalletActions.SET, handleNewWallet),
310      takeEvery(types.WalletActions.SCAN_WALLET_FOR_TOKENS, handleScanWalletAction),
311      takeEvery(types.WalletActions.SET_WALLET_TOKENS, handleSetWalletTokens),
312      takeEvery(types.WalletActions.SET_TOKEN_BALANCE_PENDING, updateTokenBalance),
313      takeEvery(types.WalletActions.REFRESH_ACCOUNT_BALANCE, updateAccountBalance),
314      takeEvery(types.WalletActions.REFRESH_TOKEN_BALANCES, retryTokenBalances),
315      // Foreign actions
316      takeEvery(configMetaTypes.CONFIG_META.TOGGLE_OFFLINE, updateBalances),
317      takeEvery(customTokensTypes.CustomTokensActions.ADD, handleCustomTokenAdd)
318    ];
319  }