diff --git a/app/components/UI/DrawerView/index.js b/app/components/UI/DrawerView/index.js index fb48b8e3c45..eff43e72076 100644 --- a/app/components/UI/DrawerView/index.js +++ b/app/components/UI/DrawerView/index.js @@ -1025,10 +1025,11 @@ class DrawerView extends PureComponent { renderFromWei(accounts[selectedAddress].balance)) || 0; const fiatBalance = Engine.getTotalFiatAccountBalance(); - if (fiatBalance !== this.previousBalance) { + const totalFiatBalance = fiatBalance.ethFiat + fiatBalance.tokenFiat; + if (totalFiatBalance !== Number(this.previousBalance)) { this.previousBalance = this.currentBalance; } - this.currentBalance = fiatBalance; + this.currentBalance = totalFiatBalance; const fiatBalanceStr = renderFiat(this.currentBalance, currentCurrency); const accountName = isDefaultAccountName(name) && ens ? ens : name; diff --git a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts index da1fa609867..0eff2fde1a4 100644 --- a/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts +++ b/app/components/UI/NetworkVerificationInfo/NetworkVerificationInfo.styles.ts @@ -52,6 +52,7 @@ const styleSheet = (params: { theme: Theme }) => { boldText: { ...typography.sBodyMDBold, } as TextStyle, + networkSection: { marginBottom: 16 }, nestedScrollContent: { paddingBottom: 24 }, }); diff --git a/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.test.ts b/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.test.ts new file mode 100644 index 00000000000..85f9c63d142 --- /dev/null +++ b/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.test.ts @@ -0,0 +1,302 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import useIsOriginalNativeTokenSymbol from './useIsOriginalNativeTokenSymbol'; +import initialBackgroundState from '../../../../util/test/initial-background-state.json'; +import axios from 'axios'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +describe('useNativeTokenFiatAmount', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const mockSelectorState = (state: any) => { + (useSelector as jest.MockedFn).mockImplementation( + (selector) => selector(state), + ); + }; + it('should return the correct value when the native symbol matches the ticker', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + + // Mock the safeChainsList response + const safeChainsList = [ + { + chainId: 1, + nativeCurrency: { + symbol: 'ETH', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('0x1', 'ETH', 'mainnet'), + ); + }); + + // Expect the hook to return true when the native symbol matches the ticker + expect(result?.result.current).toBe(true); + expect(spyFetch).not.toHaveBeenCalled(); + }); + + it('should return the correct value when the native symbol does not match the ticker', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + // Mock the safeChainsList response with a different native symbol + const safeChainsList = [ + { + chainId: 1, + nativeCurrency: { + symbol: 'BTC', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('314', 'FIL', 'mainnet'), + ); + }); + + // Expect the hook to return false when the native symbol does not match the ticker + expect(result.result.current).toBe(false); + expect(spyFetch).toHaveBeenCalled(); + }); + + it('should return false if fetch chain list throw an error', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + + // Mock the fetchWithCache function to throw an error + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => { + throw new Error('error'); + }); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('314', 'FIL', 'mainnet'), + ); + }); + + // Expect the hook to return false when the native symbol does not match the ticker + expect(result.result.current).toBe(false); + expect(spyFetch).toHaveBeenCalled(); + }); + + it('should return the correct value when the chainId is in the CURRENCY_SYMBOL_BY_CHAIN_ID', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + + // Mock the safeChainsList response with a different native symbol + const safeChainsList = [ + { + chainId: 1, + nativeCurrency: { + symbol: 'BTC', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('0x5', 'GoerliETH', 'goerli'), + ); + }); + // expect this to pass because the chainId is in the CURRENCY_SYMBOL_BY_CHAIN_ID + expect(result.result.current).toBe(true); + // expect that the chainlist API was not called + expect(spyFetch).not.toHaveBeenCalled(); + }); + + it('should return the correct value when the chainId is not in the CURRENCY_SYMBOL_BY_CHAIN_ID', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + + // Mock the safeChainsList response + const safeChainsList = [ + { + chainId: 314, + nativeCurrency: { + symbol: 'FIL', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('314', 'FIL', 'mainnet'), + ); + }); + + // Expect the hook to return true when the native symbol matches the ticker + expect(result.result.current).toBe(true); + // Expect the chainslist API to have been called + expect(spyFetch).toHaveBeenCalled(); + }); + + it('should return true if chain safe validation is disabled', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: false, + }, + }, + }, + }); + + // Mock the safeChainsList response with a different native symbol + const safeChainsList = [ + { + chainId: 1, + nativeCurrency: { + symbol: 'ETH', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('5', 'ETH', 'goerli'), + ); + }); + + expect(result.result.current).toBe(true); + expect(spyFetch).not.toHaveBeenCalled(); + }); + + it('should return the correct value for LineaGoerli testnet', async () => { + mockSelectorState({ + engine: { + backgroundState: { + ...initialBackgroundState, + PreferencesController: { + useSafeChainsListValidation: true, + }, + }, + }, + }); + + // Mock the safeChainsList response with a different native symbol + const safeChainsList = [ + { + chainId: 1, + nativeCurrency: { + symbol: 'BTC', + }, + }, + ]; + + // Mock the fetchWithCache function to return the safeChainsList + const spyFetch = jest.spyOn(axios, 'get').mockImplementation(() => + Promise.resolve({ + data: safeChainsList, + }), + ); + + let result: any; + + await act(async () => { + result = renderHook(() => + useIsOriginalNativeTokenSymbol('0xe704', 'LineaETH', 'linea'), + ); + }); + // expect this to pass because the chainId is in the CURRENCY_SYMBOL_BY_CHAIN_ID + expect(result.result.current).toBe(true); + // expect that the chainlist API was not called + expect(spyFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.ts b/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.ts new file mode 100644 index 00000000000..7b0e5f59099 --- /dev/null +++ b/app/components/UI/Ramp/hooks/useIsOriginalNativeTokenSymbol.ts @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { CURRENCY_SYMBOL_BY_CHAIN_ID } from '../../../../../app/constants/network'; +import { selectUseSafeChainsListValidation } from '../../../../../app/selectors/preferencesController'; +import axios from 'axios'; + +const CHAIN_ID_NETWORK_URL = 'https://chainid.network/chains.json'; + +/** + * Hook that check if the used symbol match with the original symbol of given network + * @returns Boolean indicating if the native symbol is correct + */ + +function useIsOriginalNativeTokenSymbol( + chainId: string, + ticker: string | undefined, + type: string, +): boolean { + const [isOriginalNativeSymbol, setIsOriginalNativeSymbol] = + useState(false); + + const useSafeChainsListValidation = useSelector( + selectUseSafeChainsListValidation, + ); + + useEffect(() => { + async function getNativeTokenSymbol(networkId: string) { + try { + // Skip if the network doesn't have symbol + if (!ticker) { + setIsOriginalNativeSymbol(true); + return; + } + + // Skip network safety checks and warning tooltip if privacy toggle is off. + if (!useSafeChainsListValidation) { + setIsOriginalNativeSymbol(true); + return; + } + + // check first on the CURRENCY_SYMBOL_BY_CHAIN_ID + const mappedCurrencySymbol = CURRENCY_SYMBOL_BY_CHAIN_ID[networkId]; + + if (mappedCurrencySymbol) { + setIsOriginalNativeSymbol(mappedCurrencySymbol === ticker); + return; + } + + // check safety network using a third part + const { data: safeChainsList } = await axios.get(CHAIN_ID_NETWORK_URL); + + const matchedChain = safeChainsList.find( + (network: { chainId: number }) => + network.chainId === parseInt(networkId), + ); + + const symbol = matchedChain?.nativeCurrency?.symbol ?? null; + setIsOriginalNativeSymbol(symbol === ticker); + return; + } catch (err) { + setIsOriginalNativeSymbol(false); + } + } + getNativeTokenSymbol(chainId); + }, [ + isOriginalNativeSymbol, + chainId, + ticker, + type, + useSafeChainsListValidation, + ]); + + return isOriginalNativeSymbol; +} + +export default useIsOriginalNativeTokenSymbol; diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index c6fd1399a80..c864d897441 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -699,6 +699,35 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` < $0.01 + + + + + + + = ({ tokens }) => { const { colors } = useTheme(); @@ -99,6 +110,7 @@ const Tokens: React.FC = ({ tokens }) => { const [isAddTokenEnabled, setIsAddTokenEnabled] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [showScamWarningModal, setShowScamWarningModal] = useState(false); const [isNetworkRampSupported, isNativeTokenRampSupported] = useRampNetwork(); const actionSheet = useRef(); @@ -107,6 +119,7 @@ const Tokens: React.FC = ({ tokens }) => { const providerConfig = selectProviderConfig(state); return getNetworkNameFromProviderConfig(providerConfig); }); + const { type, rpcUrl } = useSelector(selectProviderConfig); const chainId = useSelector(selectChainId); const ticker = useSelector(selectTicker); const currentCurrency = useSelector(selectCurrentCurrency); @@ -123,6 +136,12 @@ const Tokens: React.FC = ({ tokens }) => { const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); const browserTabs = useSelector((state: any) => state.browser.tabs); + const isOriginalNativeTokenSymbol = useIsOriginalNativeTokenSymbol( + chainId, + ticker, + type, + ); + const renderEmpty = () => ( {strings('wallet.no_tokens')} @@ -135,6 +154,68 @@ const Tokens: React.FC = ({ tokens }) => { }); }; + const goToNetworkEdit = () => { + navigation.navigate(Routes.ADD_NETWORK, { + network: rpcUrl, + isEdit: true, + }); + + setShowScamWarningModal(false); + }; + + const renderScamWarningIcon = (asset) => { + if (!isOriginalNativeTokenSymbol && asset.isETH) { + return ( + { + setShowScamWarningModal(true); + }} + variant={ButtonIconVariants.Primary} + size={IconSize.Lg} + iconColorOverride={IconColor.Error} + /> + ); + } + return null; + }; + + const renderScamWarningModal = () => ( + setShowScamWarningModal(false)} + onSwipeComplete={() => setShowScamWarningModal(false)} + swipeDirection="down" + propagateSwipe + avoidKeyboard + style={styles.bottomModal} + backdropColor={colors.overlay.default} + backdropOpacity={1} + > + + + + + + + {strings('wallet.network_not_matching')} + {` ${ticker},`} + {strings('wallet.target_scam_network')} + + + +