diff --git a/src/logic/exceptions/CodedException.test.ts b/src/logic/exceptions/CodedException.test.ts new file mode 100644 index 00000000..4934d4db --- /dev/null +++ b/src/logic/exceptions/CodedException.test.ts @@ -0,0 +1,83 @@ +import { Errors, logError, CodedException } from './CodedException' +import * as constants from 'src/utils/constants' +import * as Sentry from '@sentry/react' + +jest.mock('@sentry/react') +jest.mock('src/utils/constants') + +describe('CodedException', () => { + it('throws an error if code is not found', () => { + expect(Errors.___0).toBe('0: No such error code') + + expect(() => { + new CodedException('weird error' as any) + }).toThrow('0: No such error code (weird error)') + }) + + it('creates an error', () => { + const err = new CodedException(Errors._100) + expect(err.message).toBe('100: Invalid input in the address field') + expect(err.code).toBe(100) + }) + + it('creates an error with an extra message', () => { + const err = new CodedException(Errors._100, '0x123') + expect(err.message).toBe('100: Invalid input in the address field (0x123)') + expect(err.code).toBe(100) + }) + + describe('Logging', () => { + beforeAll(() => { + jest.mock('console') + console.error = jest.fn() + }) + afterEach(() => { + jest.unmock('console') + ;(constants as any).IS_PRODUCTION = false + }) + + it('logs to the console', () => { + const err = logError(Errors._100, '123') + expect(err.message).toBe('100: Invalid input in the address field (123)') + expect(console.error).toHaveBeenCalledWith(err) + }) + + it('logs to the console via the public log method', () => { + const err = new CodedException(Errors._601) + expect(err.message).toBe('601: Error fetching balances') + expect(console.error).not.toHaveBeenCalled() + err.log() + expect(console.error).toHaveBeenCalledWith(err) + }) + + it('logs only the error message on prod', () => { + ;(constants as any).IS_PRODUCTION = true + logError(Errors._100) + expect(console.error).toHaveBeenCalledWith('100: Invalid input in the address field') + }) + }) + + describe('Tracking', () => { + afterEach(() => { + ;(constants as any).IS_PRODUCTION = false + }) + + it('tracks using Sentry on production', () => { + ;(constants as any).IS_PRODUCTION = true + logError(Errors._100) + expect(Sentry.captureException).toHaveBeenCalled() + }) + + it("doesn't track when isTracked is false", () => { + ;(constants as any).IS_PRODUCTION = true + logError(Errors._100, '', false) + expect(Sentry.captureException).not.toHaveBeenCalled() + }) + + it('does not track using Sentry in non-production envs', () => { + ;(constants as any).IS_PRODUCTION = false + logError(Errors._100) + expect(Sentry.captureException).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/logic/exceptions/CodedException.ts b/src/logic/exceptions/CodedException.ts new file mode 100644 index 00000000..bf383300 --- /dev/null +++ b/src/logic/exceptions/CodedException.ts @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/react' +import ErrorCodes from './registry' +import { IS_PRODUCTION } from 'src/utils/constants' + +export class CodedException extends Error { + public readonly message: string + public readonly code: number + + constructor(content: ErrorCodes, extraMessage?: string) { + super() + + const codePrefix = content.split(':')[0] + const code = Number(codePrefix) + if (isNaN(code)) { + throw new CodedException(ErrorCodes.___0, codePrefix) + } + + const extraInfo = extraMessage ? ` (${extraMessage})` : '' + this.message = `${content}${extraInfo}` + this.code = code + } + + /** + * Log the error in the console and send to Sentry + */ + public log(isTracked = true): void { + // Log only the message on prod, and the full error on dev + console.error(IS_PRODUCTION ? this.message : this) + + if (IS_PRODUCTION && isTracked) { + Sentry.captureException(this) + } + } +} + +export function logError(content: ErrorCodes, extraMessage?: string, isTracked?: boolean): CodedException { + const error = new CodedException(content, extraMessage) + error.log(isTracked) + return error +} + +export const Errors = ErrorCodes diff --git a/src/logic/exceptions/registry.ts b/src/logic/exceptions/registry.ts new file mode 100644 index 00000000..18c3d3b0 --- /dev/null +++ b/src/logic/exceptions/registry.ts @@ -0,0 +1,14 @@ +/** + * When creating a new error type, please try to group them semantically + * with the existing errors in the same hundred. For example, if it's + * related to fetching data from the backend, add it to the 6xx errors. + * This is not a hard requirement, just a useful convention. + */ +enum ErrorCodes { + ___0 = '0: No such error code', + _100 = '100: Invalid input in the address field', + _600 = '600: Error fetching token list', + _601 = '601: Error fetching balances', +} + +export default ErrorCodes diff --git a/src/logic/tokens/store/actions/fetchSafeTokens.ts b/src/logic/tokens/store/actions/fetchSafeTokens.ts index ff00da66..2c67a39a 100644 --- a/src/logic/tokens/store/actions/fetchSafeTokens.ts +++ b/src/logic/tokens/store/actions/fetchSafeTokens.ts @@ -1,7 +1,11 @@ import { List } from 'immutable' import { Dispatch } from 'redux' -import { fetchTokenCurrenciesBalances, TokenBalance } from 'src/logic/safe/api/fetchTokenCurrenciesBalances' +import { + BalanceEndpoint, + fetchTokenCurrenciesBalances, + TokenBalance, +} from 'src/logic/safe/api/fetchTokenCurrenciesBalances' import { addTokens } from 'src/logic/tokens/store/actions/addTokens' import { makeToken, Token } from 'src/logic/tokens/store/model/token' import { updateSafe } from 'src/logic/safe/store/actions/updateSafe' @@ -11,6 +15,7 @@ import { safeSelector } from 'src/logic/safe/store/selectors' import BigNumber from 'bignumber.js' import { currentCurrencySelector } from 'src/logic/currencyValues/store/selectors' import { ZERO_ADDRESS, sameAddress } from 'src/logic/wallets/ethAddresses' +import { Errors, logError } from 'src/logic/exceptions/CodedException' export type BalanceRecord = { tokenAddress?: string @@ -50,39 +55,38 @@ export const fetchSafeTokens = (safeAddress: string, currencySelected?: string) dispatch: Dispatch, getState: () => AppReduxState, ): Promise => { + const state = getState() + const safe = safeSelector(state) + + if (!safe) { + return + } + const selectedCurrency = currentCurrencySelector(state) + + let tokenCurrenciesBalances: BalanceEndpoint try { - const state = getState() - const safe = safeSelector(state) - - if (!safe) { - return - } - const selectedCurrency = currentCurrencySelector(state) - - const tokenCurrenciesBalances = await fetchTokenCurrenciesBalances({ + tokenCurrenciesBalances = await fetchTokenCurrenciesBalances({ safeAddress, selectedCurrency: currencySelected ?? selectedCurrency, }) - - const { balances, ethBalance, tokens } = tokenCurrenciesBalances.items.reduce( - extractDataFromResult, - { - balances: [], - ethBalance: '0', - tokens: List(), - }, - ) - - dispatch( - updateSafe({ - address: safeAddress, - balances, - ethBalance, - totalFiatBalance: new BigNumber(tokenCurrenciesBalances.fiatTotal).toFixed(2), - }), - ) - dispatch(addTokens(tokens)) - } catch (err) { - console.error('Error fetching active token list', err) + } catch (e) { + logError(Errors._601, e.message, false) + return } + + const { balances, ethBalance, tokens } = tokenCurrenciesBalances.items.reduce(extractDataFromResult, { + balances: [], + ethBalance: '0', + tokens: List(), + }) + + dispatch( + updateSafe({ + address: safeAddress, + balances, + ethBalance, + totalFiatBalance: new BigNumber(tokenCurrenciesBalances.fiatTotal).toFixed(2), + }), + ) + dispatch(addTokens(tokens)) } diff --git a/src/logic/tokens/store/actions/fetchTokens.ts b/src/logic/tokens/store/actions/fetchTokens.ts index 7c42186a..cf47e94f 100644 --- a/src/logic/tokens/store/actions/fetchTokens.ts +++ b/src/logic/tokens/store/actions/fetchTokens.ts @@ -12,6 +12,8 @@ import { AppReduxState } from 'src/store' import { ensureOnce } from 'src/utils/singleton' import { ThunkDispatch } from 'redux-thunk' import { AnyAction } from 'redux' +import { Errors, logError } from 'src/logic/exceptions/CodedException' +import { TokenResult } from '../../api/fetchErc20AndErc721AssetsList' const createStandardTokenContract = async () => { const web3 = getWeb3() @@ -52,23 +54,24 @@ export const fetchTokens = () => async ( dispatch: ThunkDispatch, getState: () => AppReduxState, ): Promise => { + const currentSavedTokens = tokensSelector(getState()) + + let tokenList: TokenResult[] try { - const currentSavedTokens = tokensSelector(getState()) - - const { - data: { results: tokenList }, - } = await fetchErc20AndErc721AssetsList() - - const erc20Tokens = tokenList.filter((token) => token.type.toLowerCase() === 'erc20') - - if (currentSavedTokens?.size === erc20Tokens.length) { - return - } - - const tokens = List(erc20Tokens.map((token) => makeToken(token))) - - dispatch(addTokens(tokens)) - } catch (err) { - console.error('Error fetching token list', err) + const resp = await fetchErc20AndErc721AssetsList() + tokenList = resp.data.results + } catch (e) { + logError(Errors._600, e.message, false) + return } + + const erc20Tokens = tokenList.filter((token) => token.type.toLowerCase() === 'erc20') + + if (currentSavedTokens?.size === erc20Tokens.length) { + return + } + + const tokens = List(erc20Tokens.map((token) => makeToken(token))) + + dispatch(addTokens(tokens)) }