diff --git a/app/components/Views/confirmations/components/info/transfer/transfer.tsx b/app/components/Views/confirmations/components/info/transfer/transfer.tsx index 8bc3142378f2..767277d5308e 100644 --- a/app/components/Views/confirmations/components/info/transfer/transfer.tsx +++ b/app/components/Views/confirmations/components/info/transfer/transfer.tsx @@ -28,7 +28,7 @@ const Transfer = () => { return ( - + diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.styles.ts b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.styles.ts new file mode 100644 index 000000000000..9e6aa550a016 --- /dev/null +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../../../../util/theme/models'; + +export const styleSheet = (params: { + theme: Theme; +}) => { + const { theme } = params; + return StyleSheet.create({ + avatarToken: { + backgroundColor: theme.colors.background.default, + borderRadius: 99, + }, + }); +}; diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx new file mode 100644 index 000000000000..a8b6ea0c55a5 --- /dev/null +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { TransactionMeta } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; + +import { AvatarSize } from '../../../../../../../../component-library/components/Avatars/Avatar/Avatar.types'; +import AvatarToken from '../../../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken'; +import Badge, { + BadgeVariant, +} from '../../../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../../../component-library/components/Badges/BadgeWrapper'; +import { useStyles } from '../../../../../../../../component-library/hooks'; +import { + CHAINLIST_CURRENCY_SYMBOLS_MAP, + NETWORKS_CHAIN_ID, +} from '../../../../../../../../constants/network'; +import NetworkAssetLogo from '../../../../../../../UI/NetworkAssetLogo'; +import { TokenI } from '../../../../../../../UI/Tokens/types'; +import useNetworkInfo from '../../../../../hooks/useNetworkInfo'; +import { useTokenAsset } from '../../../../../hooks/useTokenAsset'; +import { useTransactionMetadataRequest } from '../../../../../hooks/transactions/useTransactionMetadataRequest'; +import { styleSheet } from './avatar-token-with-network-badge.styles'; + +const AvatarTokenOrNetworkAssetLogo = ({ + asset, + chainId, +}: { + asset: TokenI; + chainId: Hex; +}) => { + const { styles } = useStyles(styleSheet, {}); + const { image, isNative, name, symbol, ticker } = asset; + + return isNative ? ( + + ) : ( + + ); +}; + +export const AvatarTokenWithNetworkBadge = ({ + canShowBadge = true, +}: { + canShowBadge?: boolean; +}) => { + const { chainId } = + useTransactionMetadataRequest() ?? ({} as TransactionMeta); + const { asset } = useTokenAsset(); + + const { networkName, networkImage } = useNetworkInfo(chainId); + const { symbol } = asset; + + const isEthOnMainnet = + chainId === NETWORKS_CHAIN_ID.MAINNET && + symbol === CHAINLIST_CURRENCY_SYMBOLS_MAP.MAINNET; + const showBadge = canShowBadge && networkImage && !isEthOnMainnet; + + return showBadge ? ( + + } + > + + + ) : ( + + ); +}; diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/index.ts b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/index.ts new file mode 100644 index 000000000000..07efbfb3c88f --- /dev/null +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/avatar-token-with-network-badge/index.ts @@ -0,0 +1 @@ +export { AvatarTokenWithNetworkBadge } from './avatar-token-with-network-badge'; diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.styles.ts b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.styles.ts index 3be254fdae45..c5f1064d522a 100644 --- a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.styles.ts @@ -19,19 +19,15 @@ const styleSheet = (params: { textAlign: 'center', color: theme.colors.text.alternative, }, - networkAndTokenContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - networkLogo: { - width: 48, - height: 48, - }, container: { paddingBottom: 16, paddingTop: isFlatConfirmation ? 16 : 0, }, + containerAvatarTokenNetworkWithBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.test.tsx b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.test.tsx index e56b470c1221..3eba6201c941 100644 --- a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import renderWithProvider, { DeepPartial } from '../../../../../../../util/test/renderWithProvider'; -import { stakingDepositConfirmationState } from '../../../../../../../util/test/confirm-data-helpers'; +import { stakingDepositConfirmationState, transferConfirmationState } from '../../../../../../../util/test/confirm-data-helpers'; import TokenHero from './token-hero'; import { fireEvent, waitFor } from '@testing-library/react-native'; import { merge } from 'lodash'; @@ -17,16 +17,54 @@ jest.mock('../../../../../../../core/Engine', () => ({ })); describe('TokenHero', () => { - it('contains token and fiat values for staking deposit', async () => { - const { getByText } = renderWithProvider(, { + it('displays avatar, amount, and fiat values for a simple send transfer', async () => { + const state: DeepPartial = merge( + {}, + transferConfirmationState, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { txParams: { value: `0x${decGWEIToHexWEI(55555555)}` } }, + ], + }, + }, + }, + }, + ); + const { getByText, queryByTestId } = renderWithProvider(, { state }); + + expect(queryByTestId('avatar-with-badge-avatar-token-ETH')).toBeTruthy(); + + await waitFor(async () => { + expect(getByText('0.0556 ETH')).toBeDefined(); + expect(getByText('$199.79')).toBeDefined(); + }); + + const tokenAmountText = getByText('0.0556 ETH'); + fireEvent.press(tokenAmountText); + + await waitFor(() => { + expect(getByText('Amount')).toBeDefined(); + expect(getByText('0.055555555')).toBeDefined(); + }); + }); + + it('displays avatar, amount, and fiat values for staking deposit', async () => { + const { getByText, queryByTestId } = renderWithProvider(, { state: stakingDepositConfirmationState, }); - expect(getByText('0.0001 ETH')).toBeDefined(); - expect(getByText('$0.36')).toBeDefined(); + expect(queryByTestId('avatar-with-badge-avatar-token-ETH')).toBeTruthy(); + + await waitFor(async () => { + expect(getByText('0.0001 ETH')).toBeDefined(); + expect(getByText('$0.36')).toBeDefined(); + }); }); - it('contains token and fiat values for staking deposit', async () => { + it('displays avatar, rounded amount, amount, and fiat values for staking deposit', async () => { const state: DeepPartial = merge( {}, stakingDepositConfirmationState, @@ -43,13 +81,17 @@ describe('TokenHero', () => { }, ); - const { getByText } = renderWithProvider( + const { getByText, queryByTestId } = renderWithProvider( , { state }, ); - expect(getByText('0.0123 ETH')).toBeDefined(); - expect(getByText('$44.40')).toBeDefined(); + expect(queryByTestId('avatar-with-badge-avatar-token-ETH')).toBeTruthy(); + + await waitFor(() => { + expect(getByText('0.0123 ETH')).toBeDefined(); + expect(getByText('$44.40')).toBeDefined(); + }); const tokenAmountText = getByText('0.0123 ETH'); fireEvent.press(tokenAmountText); diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx index fa423f5f5828..ecc68a4e9a1a 100644 --- a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx @@ -2,53 +2,29 @@ import React, { useState } from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../../locales/i18n'; -import Badge, { - BadgeVariant, -} from '../../../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../../../component-library/components/Badges/BadgeWrapper'; import Text, { TextVariant, } from '../../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../../component-library/hooks'; -import images from '../../../../../../../images/image-icons'; import { selectTransactionState } from '../../../../../../../reducers/transaction'; -import TokenIcon from '../../../../../../UI/Swaps/components/TokenIcon'; +import useHideFiatForTestnet from '../../../../../../hooks/useHideFiatForTestnet'; import { useConfirmationContext } from '../../../../context/confirmation-context'; -import { useTokenValues } from '../../../../hooks/useTokenValues'; import { useFlatConfirmation } from '../../../../hooks/ui/useFlatConfirmation'; +import { useTokenAsset } from '../../../../hooks/useTokenAsset'; +import { useTokenAmount } from '../../../../hooks/useTokenAmount'; import AnimatedPulse from '../../../UI/animated-pulse'; import { TooltipModal } from '../../../UI/Tooltip/Tooltip'; +import { AvatarTokenWithNetworkBadge } from './avatar-token-with-network-badge'; import styleSheet from './token-hero.styles'; -const NetworkAndTokenImage = ({ - tokenSymbol, - styles, -}: { - tokenSymbol: string; - styles: StyleSheet.NamedStyles>; -}) => ( - - - } - > - - - -); - const AssetAmount = ({ - tokenAmountDisplayValue, + amountDisplay, tokenSymbol, styles, setIsModalVisible, }: { - tokenAmountDisplayValue: string; - tokenSymbol: string; + amountDisplay?: string; + tokenSymbol?: string; styles: StyleSheet.NamedStyles>; setIsModalVisible: ((isModalVisible: boolean) => void) | null; }) => ( @@ -56,44 +32,59 @@ const AssetAmount = ({ {setIsModalVisible ? ( setIsModalVisible(true)}> - {tokenAmountDisplayValue} {tokenSymbol} + {amountDisplay} {tokenSymbol} ) : ( - {tokenAmountDisplayValue} {tokenSymbol} + {amountDisplay} {tokenSymbol} )} ); const AssetFiatConversion = ({ - fiatDisplayValue, + fiatDisplay, styles, }: { - fiatDisplayValue: string; + fiatDisplay?: string; styles: StyleSheet.NamedStyles>; -}) => ( - - {fiatDisplayValue} - -); +}) => { + const hideFiatForTestnet = useHideFiatForTestnet(); + if (hideFiatForTestnet || !fiatDisplay) { + return null; + } + + return ( + + {fiatDisplay} + + ); +}; + +const TokenHero = ({ + amountWei, + showNetworkBadge = true, +}: { + amountWei?: string; + showNetworkBadge?: boolean; +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); -const TokenHero = ({ amountWei }: { amountWei?: string }) => { const { isTransactionValueUpdating } = useConfirmationContext(); const { isFlatConfirmation } = useFlatConfirmation(); const { maxValueMode } = useSelector(selectTransactionState); const { styles } = useStyles(styleSheet, { isFlatConfirmation, }); - const { tokenAmountValue, tokenAmountDisplayValue, fiatDisplayValue } = - useTokenValues({ amountWei }); - const [isModalVisible, setIsModalVisible] = useState(false); - const displayTokenAmountIsRounded = - tokenAmountValue !== tokenAmountDisplayValue; + const { amountPreciseDisplay, amountDisplay, fiatDisplay } = + useTokenAmount({ amountWei }); + const { + asset: { symbol, ticker }, + } = useTokenAsset(); - const tokenSymbol = 'ETH'; + const isRoundedAmount = amountPreciseDisplay !== amountDisplay; return ( { preventPulse={!maxValueMode} > - + + + - - {displayTokenAmountIsRounded && ( + + {isRoundedAmount && ( diff --git a/app/components/Views/confirmations/constants/tokens.ts b/app/components/Views/confirmations/constants/tokens.ts index 88f59cd469ef..716c571a1cb9 100644 --- a/app/components/Views/confirmations/constants/tokens.ts +++ b/app/components/Views/confirmations/constants/tokens.ts @@ -1,3 +1,5 @@ +export const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; + export const TOKEN_ADDRESS = { DAI: '0x6b175474e89094c44da98b954eedeac495271d0f', }; diff --git a/app/components/Views/confirmations/external/staking/info/staking-claim/staking-claim.tsx b/app/components/Views/confirmations/external/staking/info/staking-claim/staking-claim.tsx index 13e1d54950a1..ef0ab7054fc3 100644 --- a/app/components/Views/confirmations/external/staking/info/staking-claim/staking-claim.tsx +++ b/app/components/Views/confirmations/external/staking/info/staking-claim/staking-claim.tsx @@ -9,7 +9,7 @@ import { EVENT_PROVIDERS } from '../../../../../../UI/Stake/constants/events'; import useClearConfirmationOnBackSwipe from '../../../../hooks/ui/useClearConfirmationOnBackSwipe'; import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfirmationMetricEvents'; import useNavbar from '../../../../hooks/ui/useNavbar'; -import { useTokenValues } from '../../../../hooks/useTokenValues'; +import { useTokenAmount } from '../../../../hooks/useTokenAmount'; import { useTransactionMetadataRequest } from '../../../../hooks/transactions/useTransactionMetadataRequest'; import InfoSection from '../../../../components/UI/info-row/info-section'; import StakingContractInteractionDetails from '../../components/staking-contract-interaction-details/staking-contract-interaction-details'; @@ -30,15 +30,15 @@ const StakingClaim = ({ const { trackPageViewedEvent, setConfirmationMetric } = useConfirmationMetricEvents(); const amountWei = route?.params?.amountWei; - const { tokenAmountDisplayValue } = useTokenValues({ amountWei }); + const { amountDisplay } = useTokenAmount({ amountWei }); useEffect(() => { setConfirmationMetric({ properties: { selected_provider: EVENT_PROVIDERS.CONSENSYS, - transaction_amount_eth: tokenAmountDisplayValue, + transaction_amount_eth: amountDisplay, }, }); - }, [tokenAmountDisplayValue, setConfirmationMetric]); + }, [amountDisplay, setConfirmationMetric]); useEffect(trackPageViewedEvent, [trackPageViewedEvent]); diff --git a/app/components/Views/confirmations/external/staking/info/staking-deposit/staking-deposit.tsx b/app/components/Views/confirmations/external/staking/info/staking-deposit/staking-deposit.tsx index 6e2baae9d18d..6ce7e9e2586f 100644 --- a/app/components/Views/confirmations/external/staking/info/staking-deposit/staking-deposit.tsx +++ b/app/components/Views/confirmations/external/staking/info/staking-deposit/staking-deposit.tsx @@ -4,7 +4,7 @@ import { EVENT_PROVIDERS } from '../../../../../../UI/Stake/constants/events'; import useClearConfirmationOnBackSwipe from '../../../../hooks/ui/useClearConfirmationOnBackSwipe'; import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfirmationMetricEvents'; import useNavbar from '../../../../hooks/ui/useNavbar'; -import { useTokenValues } from '../../../../hooks/useTokenValues'; +import { useTokenAmount } from '../../../../hooks/useTokenAmount'; import InfoSectionAccordion from '../../../../components/UI/info-section-accordion'; import StakingContractInteractionDetails from '../../components/staking-contract-interaction-details/staking-contract-interaction-details'; import StakingDetails from '../../components/staking-details/staking-details'; @@ -20,15 +20,15 @@ const StakingDeposit = () => { trackPageViewedEvent, setConfirmationMetric, } = useConfirmationMetricEvents(); - const { tokenAmountDisplayValue } = useTokenValues(); + const { amountDisplay } = useTokenAmount(); useEffect(() => { setConfirmationMetric({ properties: { selected_provider: EVENT_PROVIDERS.CONSENSYS, - transaction_amount_eth: tokenAmountDisplayValue, + transaction_amount_eth: amountDisplay, }, }); - }, [tokenAmountDisplayValue, setConfirmationMetric]); + }, [amountDisplay, setConfirmationMetric]); useEffect(() => { trackPageViewedEvent(); diff --git a/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.test.tsx b/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.test.tsx index 674742018334..b56a15b701e9 100644 --- a/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.test.tsx +++ b/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { waitFor } from '@testing-library/react-native'; import { stakingWithdrawalConfirmationState } from '../../../../../../../util/test/confirm-data-helpers'; import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; import { EVENT_PROVIDERS } from '../../../../../../UI/Stake/constants/events'; @@ -35,6 +36,11 @@ jest.mock('../../../../components/UI/navbar/navbar', () => ({ getNavbar: jest.fn(), })); +jest.mock('../../../../utils/token', () => ({ + ...jest.requireActual('../../../../utils/token'), + fetchErc20Decimals: jest.fn().mockResolvedValue(18), +})); + const noop = () => undefined; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -92,6 +98,7 @@ describe('StakingWithdrawal', () => { state: stakingWithdrawalConfirmationState, }, ); + expect(getByText('Withdrawal time')).toBeDefined(); expect(getByText('Unstaking to')).toBeDefined(); @@ -109,7 +116,7 @@ describe('StakingWithdrawal', () => { }); }); - it('tracks metrics events', () => { + it('tracks metrics events', async () => { renderWithProvider( { }, ); + expect(mockTrackPageViewedEvent).toHaveBeenCalledTimes(1); - expect(mockSetConfirmationMetric).toHaveBeenCalledTimes(1); - expect(mockSetConfirmationMetric).toHaveBeenCalledWith( - expect.objectContaining({ - properties: expect.objectContaining({ - selected_provider: EVENT_PROVIDERS.CONSENSYS, - transaction_amount_eth: '1', + await waitFor(() => { + expect(mockSetConfirmationMetric).toHaveBeenCalledTimes(1); + expect(mockSetConfirmationMetric).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + transaction_amount_eth: '1', + }), }), - }), - ); + ); + }); }); }); diff --git a/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.tsx b/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.tsx index 447570beef98..c351bb169e37 100644 --- a/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.tsx +++ b/app/components/Views/confirmations/external/staking/info/staking-withdrawal/staking-withdrawal.tsx @@ -5,7 +5,7 @@ import { EVENT_PROVIDERS } from '../../../../../../UI/Stake/constants/events'; import useClearConfirmationOnBackSwipe from '../../../../hooks/ui/useClearConfirmationOnBackSwipe'; import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfirmationMetricEvents'; import useNavbar from '../../../../hooks/ui/useNavbar'; -import { useTokenValues } from '../../../../hooks/useTokenValues'; +import { useTokenAmount } from '../../../../hooks/useTokenAmount'; import InfoSection from '../../../../components/UI/info-row/info-section'; import StakingContractInteractionDetails from '../../components/staking-contract-interaction-details/staking-contract-interaction-details'; import TokenHero from '../../../../components/rows/transactions/token-hero'; @@ -19,16 +19,21 @@ const StakingWithdrawal = ({ route }: UnstakeConfirmationViewProps) => { useClearConfirmationOnBackSwipe(); const { trackPageViewedEvent, setConfirmationMetric } = - useConfirmationMetricEvents(); - const { tokenAmountDisplayValue } = useTokenValues({ amountWei }); + useConfirmationMetricEvents(); + const { amountDisplay } = useTokenAmount({ amountWei }); + useEffect(() => { + if (amountDisplay === undefined) { + return; + } + setConfirmationMetric({ properties: { selected_provider: EVENT_PROVIDERS.CONSENSYS, - transaction_amount_eth: tokenAmountDisplayValue, + transaction_amount_eth: amountDisplay, }, }); - }, [tokenAmountDisplayValue, setConfirmationMetric]); + }, [amountDisplay, setConfirmationMetric]); useEffect(trackPageViewedEvent, [trackPageViewedEvent]); diff --git a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts index 8f935f5ea08c..87cddae33da8 100644 --- a/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useFeeCalculations.test.ts @@ -17,6 +17,11 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); +jest.mock('../../utils/token', () => ({ + ...jest.requireActual('../../../../utils/token'), + fetchErc20Decimals: jest.fn().mockResolvedValue(18), +})); + describe('useFeeCalculations', () => { const transactionMeta = stakingDepositConfirmationState.engine.backgroundState.TransactionController diff --git a/app/components/Views/confirmations/hooks/transactions/useSupportsEIP1559.test.ts b/app/components/Views/confirmations/hooks/transactions/useSupportsEIP1559.test.ts index d6d8c765c058..41cd6287907a 100644 --- a/app/components/Views/confirmations/hooks/transactions/useSupportsEIP1559.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useSupportsEIP1559.test.ts @@ -5,7 +5,7 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvi import { stakingDepositConfirmationState } from '../../../../../util/test/confirm-data-helpers'; import { useSupportsEIP1559 } from './useSupportsEIP1559'; -describe('useEIP1559TxFees', () => { +describe('useSupportsEIP1559', () => { it('returns true for EIP1559 transaction', async () => { const { result } = renderHookWithProvider( () => diff --git a/app/components/Views/confirmations/hooks/useTokenAmount.test.ts b/app/components/Views/confirmations/hooks/useTokenAmount.test.ts new file mode 100644 index 000000000000..55feef599512 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenAmount.test.ts @@ -0,0 +1,61 @@ +import { useTokenAmount } from './useTokenAmount'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { + stakingDepositConfirmationState, + transferConfirmationState, +} from '../../../../util/test/confirm-data-helpers'; +import { waitFor } from '@testing-library/react-native'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + TokenListController: { + fetchTokenList: jest.fn(), + }, + }, +})); + +describe('useTokenAmount', () => { + describe('returns amount and fiat display values', () => { + it('for a transfer type transaction', async () => { + const { result } = renderHookWithProvider(() => useTokenAmount(), { + state: transferConfirmationState, + }); + + await waitFor(async () => { + expect(result.current).toEqual({ + amountDisplay: '0.0001', + amountPreciseDisplay: '0.0001', + fiatDisplay: '$0.36', + }); + }); + }); + + it('for a staking deposit', async () => { + const { result } = renderHookWithProvider(() => useTokenAmount(), { + state: stakingDepositConfirmationState, + }); + + await waitFor(async () => { + expect(result.current).toEqual({ + amountDisplay: '0.0001', + amountPreciseDisplay: '0.0001', + fiatDisplay: '$0.36', + }); + }); + }); + + it('for a staking deposit and with amountWei defined', async () => { + const { result } = renderHookWithProvider(() => useTokenAmount({ amountWei: '1000000000000000' }), { + state: stakingDepositConfirmationState, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + amountDisplay: '0.001', + amountPreciseDisplay: '0.001', + fiatDisplay: '$3.60', + }); + }); + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useTokenAmount.ts b/app/components/Views/confirmations/hooks/useTokenAmount.ts new file mode 100644 index 000000000000..da1cf1b8f41e --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenAmount.ts @@ -0,0 +1,92 @@ +import { BigNumber } from 'bignumber.js'; +import { useSelector } from 'react-redux'; +import { NetworkClientId } from '@metamask/network-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; + +import I18n from '../../../../../locales/i18n'; +import { formatAmount, formatAmountMaxPrecision } from '../../../UI/SimulationDetails/formatAmount'; +import { RootState } from '../../../../reducers'; +import { selectConversionRateByChainId } from '../../../../selectors/currencyRateController'; +import { selectContractExchangeRatesByChainId } from '../../../../selectors/tokenRatesController'; +import { safeToChecksumAddress } from '../../../../util/address'; +import { toBigNumber } from '../../../../util/number'; +import { calcTokenAmount } from '../../../../util/transactions'; +import { useAsyncResult } from '../../../hooks/useAsyncResult'; +import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { NATIVE_TOKEN_ADDRESS } from '../constants/tokens'; +import { ERC20_DEFAULT_DECIMALS, fetchErc20Decimals } from '../utils/token'; +import { parseStandardTokenTransactionData } from '../utils/transaction'; +import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; + +interface TokenValuesProps { + /** + * Optional value in wei to display. If not provided, the amount from the transactionMetadata will be used. + */ + amountWei?: string; +} + +interface TokenValues { + amountPreciseDisplay: string; + amountDisplay: string; + fiatDisplay: string; +} + +const useTokenDecimals = (tokenAddress: Hex, networkClientId?: NetworkClientId) => useAsyncResult( + async () => await fetchErc20Decimals(tokenAddress, networkClientId), + [tokenAddress, networkClientId], +); + +export const useTokenAmount = ({ amountWei }: TokenValuesProps = {}): TokenValues | Record => { + const fiatFormatter = useFiatFormatter(); + const { + chainId, + networkClientId, + txParams, + type: transactionType, + } = useTransactionMetadataRequest() ?? {}; + + const contractExchangeRates = useSelector((state: RootState) => + selectContractExchangeRatesByChainId(state, chainId as Hex), + ); + const nativeConversionRate = new BigNumber( + useSelector((state: RootState) => + selectConversionRateByChainId(state, chainId as Hex), + ) ?? 1, + ); + + const tokenAddress = safeToChecksumAddress(txParams?.to) || NATIVE_TOKEN_ADDRESS; + const { value: decimals, pending } = useTokenDecimals(tokenAddress, networkClientId); + + if (pending) { + return {}; + } + + const transactionData = parseStandardTokenTransactionData(txParams?.data); + const value = amountWei ? + toBigNumber.dec(amountWei) : + transactionData?.args?._value || txParams?.value || '0'; + + const amount = calcTokenAmount(value ?? '0', Number(decimals ?? ERC20_DEFAULT_DECIMALS)); + + let fiat; + + switch (transactionType) { + case TransactionType.tokenMethodTransfer: { + // ERC20 + const contractExchangeRate = contractExchangeRates?.[tokenAddress]?.price ?? 1; + fiat = amount.times(nativeConversionRate).times(contractExchangeRate); + break; + } + default: { + fiat = amount.times(nativeConversionRate); + break; + } + } + + return { + amountPreciseDisplay: formatAmountMaxPrecision(I18n.locale, amount), + amountDisplay: formatAmount(I18n.locale, amount), + fiatDisplay: fiatFormatter(fiat), + }; +}; diff --git a/app/components/Views/confirmations/hooks/useTokenAsset.test.ts b/app/components/Views/confirmations/hooks/useTokenAsset.test.ts new file mode 100644 index 000000000000..999f824ad3c7 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenAsset.test.ts @@ -0,0 +1,49 @@ +import { strings } from '../../../../../locales/i18n'; +import { useTokenAsset } from './useTokenAsset'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import { stakingDepositConfirmationState } from '../../../../util/test/confirm-data-helpers'; + +jest.mock('./transactions/useTransactionMetadataRequest', () => ({ + useTransactionMetadataRequest: jest.fn().mockReturnValue({ + chainId: '0x1', + txParams: { + to: '0x0000000000000000000000000000000000000000', + from: '0x0000000000000000000000000000000000000000', + }, + }), +})); + +describe('useTokenAsset', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns "unknown" token name and symbol when the asset symbol is not found', () => { + const { result } = renderHookWithProvider(useTokenAsset, { + state: { + engine: { + backgroundState: { + ...backgroundState, + }, + }, + }, + }); + + expect(result.current.asset).toEqual({ + name: strings('token.unknown'), + symbol: strings('token.unknown'), + }); + }); + + it('returns asset', () => { + const { result } = renderHookWithProvider(useTokenAsset, { + state: stakingDepositConfirmationState, + }); + + expect(result.current.asset).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useTokenAsset.ts b/app/components/Views/confirmations/hooks/useTokenAsset.ts new file mode 100644 index 000000000000..cf8a13a32f11 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useTokenAsset.ts @@ -0,0 +1,61 @@ +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; + +import { strings } from '../../../../../locales/i18n'; +import { TokenI } from '../../../UI/Tokens/types'; +import { RootState } from '../../../../reducers'; +import { makeSelectAssetByAddressAndChainId } from '../../../../selectors/multichain'; +import { safeToChecksumAddress } from '../../../../util/address'; +import { NATIVE_TOKEN_ADDRESS } from '../constants/tokens'; +import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; + +const selectEvmAsset = makeSelectAssetByAddressAndChainId(); + +export const useTokenAsset = () => { + const { chainId, type: transactionType, txParams } = useTransactionMetadataRequest() ?? {}; + + const tokenAddress = safeToChecksumAddress(txParams?.to)?.toLowerCase() || NATIVE_TOKEN_ADDRESS; + + const evmAsset = useSelector((state: RootState) => + selectEvmAsset(state, { + address: tokenAddress, + chainId: chainId as Hex, + }), + ); + + const nativeEvmAsset = useSelector((state: RootState) => + selectEvmAsset(state, { + address: NATIVE_TOKEN_ADDRESS, + chainId: chainId as Hex, + }), + ); + + let asset = {} as TokenI; + + switch (transactionType) { + case TransactionType.contractInteraction: + case TransactionType.stakingClaim: + case TransactionType.stakingDeposit: + case TransactionType.stakingUnstake: + case TransactionType.simpleSend: { + // Native + asset = nativeEvmAsset ?? {} as TokenI; + break; + } + default: { + // ERC20 + asset = evmAsset ?? {} as TokenI; + break; + } + } + + if (!asset.symbol && !asset.ticker) { + asset.name = strings('token.unknown'); + asset.symbol = strings('token.unknown'); + } + + return { + asset, + }; +}; diff --git a/app/components/Views/confirmations/hooks/useTokenValues.test.ts b/app/components/Views/confirmations/hooks/useTokenValues.test.ts deleted file mode 100644 index 7f1d594cc58e..000000000000 --- a/app/components/Views/confirmations/hooks/useTokenValues.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useTokenValues } from './useTokenValues'; -import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; -import { stakingDepositConfirmationState } from '../../../../util/test/confirm-data-helpers'; - -jest.mock('../../../../core/Engine', () => ({ - context: { - TokenListController: { - fetchTokenList: jest.fn(), - }, - }, -})); - -describe('useTokenValues', () => { - describe('staking deposit', () => { - it('returns token and fiat values if from transaction metadata', () => { - const { result } = renderHookWithProvider(() => useTokenValues({ amountWei: undefined }), { - state: stakingDepositConfirmationState, - }); - - expect(result.current).toEqual({ - tokenAmountValue: '0.0001', - tokenAmountDisplayValue: '0.0001', - fiatDisplayValue: '$0.36', - }); - }); - - it('returns token and fiat values if amountWei is defined', () => { - const { result } = renderHookWithProvider(() => useTokenValues({ amountWei: '1000000000000000' }), { - state: stakingDepositConfirmationState, - }); - - expect(result.current).toEqual({ - tokenAmountValue: '0.001', - tokenAmountDisplayValue: '0.001', - fiatDisplayValue: '$3.60', - }); - }); - }); -}); diff --git a/app/components/Views/confirmations/hooks/useTokenValues.ts b/app/components/Views/confirmations/hooks/useTokenValues.ts deleted file mode 100644 index ca44fa96a6c8..000000000000 --- a/app/components/Views/confirmations/hooks/useTokenValues.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useSelector } from 'react-redux'; -import { BigNumber } from 'bignumber.js'; -import { Hex } from '@metamask/utils'; - -import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; -import { selectConversionRateByChainId } from '../../../../selectors/currencyRateController'; -import I18n from '../../../../../locales/i18n'; -import { formatAmount } from '../../../../components/UI/SimulationDetails/formatAmount'; -import { fromWei, hexToBN, toBigNumber } from '../../../../util/number'; -import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; -import { RootState } from '../../../../reducers'; - -// TODO: This hook will be extended to calculate token and fiat information from transaction metadata on upcoming redesigned confirmations -export const useTokenValues = ({ amountWei }: { amountWei?: string } = {}) => { - - const transactionMetadata = useTransactionMetadataRequest(); - - let ethAmountInWei; - if (amountWei) { - ethAmountInWei = toBigNumber.dec(amountWei); - } else { - ethAmountInWei = hexToBN(transactionMetadata?.txParams?.value); - } - - const ethAmountInBN = new BigNumber(fromWei(ethAmountInWei, 'ether')); - - const tokenAmountValue = ethAmountInBN.toFixed(); - - const locale = I18n.locale; - const tokenAmountDisplayValue = formatAmount(locale, ethAmountInBN); - - const fiatFormatter = useFiatFormatter(); - const nativeConversionRate = useSelector((state: RootState) => - selectConversionRateByChainId(state, transactionMetadata?.chainId as Hex), - ); - const nativeConversionRateInBN = new BigNumber(nativeConversionRate || 1); - const preciseFiatValue = ethAmountInBN.times(nativeConversionRateInBN); - const fiatDisplayValue = preciseFiatValue && fiatFormatter(preciseFiatValue); - - return { - tokenAmountValue, - tokenAmountDisplayValue, - fiatDisplayValue, - }; -}; diff --git a/app/util/address/index.ts b/app/util/address/index.ts index 0e03c044ab82..0ba375cc0ea4 100644 --- a/app/util/address/index.ts +++ b/app/util/address/index.ts @@ -443,7 +443,7 @@ export function resemblesAddress(address: string) { return address && address.length === 2 + 20 * 2; } -export function safeToChecksumAddress(address: string) { +export function safeToChecksumAddress(address?: string) { if (!address) return undefined; return toChecksumAddress(address) as Hex; } diff --git a/app/util/test/confirm-data-helpers.ts b/app/util/test/confirm-data-helpers.ts index da6b23bb7499..a41873876d1f 100644 --- a/app/util/test/confirm-data-helpers.ts +++ b/app/util/test/confirm-data-helpers.ts @@ -519,6 +519,16 @@ const stakingConfirmationBaseState = { engine: { backgroundState: { ...backgroundState, + AccountsController: { + internalAccounts: { + accounts: { + '0x0000000000000000000000000000000000000000': { + address: '0x0000000000000000000000000000000000000000', + }, + }, + selectedAccount: '0x0000000000000000000000000000000000000000', + }, + }, ApprovalController: { pendingApprovals: { '699ca2f0-e459-11ef-b6f6-d182277cf5e1': { @@ -549,6 +559,24 @@ const stakingConfirmationBaseState = { }, }, }, + TokensController: { + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000000': [{ + address: '0x0000000000000000000000000000000000000000', + aggregators: [], + balance: '0xde0b6b3a7640000', + chainId: '0x1', + decimals: 18, + isETH: true, + isNative: true, + name: 'Ethereum', + symbol: 'ETH', + ticker: 'ETH', + }] + } + } + }, TransactionController: { transactions: [ { @@ -609,6 +637,7 @@ const stakingConfirmationBaseState = { ...confirmationRedesignRemoteFlagsState, }, NetworkController: { + ...backgroundState.NetworkController, networksMetadata: { mainnet: { EIPS: { 1559: true }, @@ -618,16 +647,7 @@ const stakingConfirmationBaseState = { }, }, networkConfigurationsByChainId: { - '0x1': { - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'mainnet', - url: 'https://mainnet.infura.io/v3/1234567890', - }, - ], - defaultRpcEndpointIndex: 0, - }, + ...backgroundState.NetworkController.networkConfigurationsByChainId, '0xaa36a7': { nativeCurrency: 'ETH', rpcEndpoints: [ @@ -639,7 +659,6 @@ const stakingConfirmationBaseState = { defaultRpcEndpointIndex: 0, }, }, - selectedNetworkClientId: 'mainnet', }, GasFeeController: { gasFeeEstimatesByChainId: { @@ -689,7 +708,10 @@ export const stakingDepositConfirmationState = merge( engine: { backgroundState: { TransactionController: { - transactions: [{ type: TransactionType.stakingDeposit }], + transactions: [{ + chainId: '0x1', + type: TransactionType.stakingDeposit, + }], } as unknown as TransactionControllerState, }, }, @@ -703,18 +725,11 @@ export const stakingWithdrawalConfirmationState = merge( engine: { backgroundState: { TransactionController: { - transactions: [{ type: TransactionType.stakingUnstake }], + transactions: [{ + chainId: '0x1', + type: TransactionType.stakingUnstake, + }], } as unknown as TransactionControllerState, - AccountsController: { - internalAccounts: { - accounts: { - '0x0000000000000000000000000000000000000000': { - address: '0x0000000000000000000000000000000000000000', - }, - }, - selectedAccount: '0x0000000000000000000000000000000000000000', - }, - }, }, }, }, @@ -727,18 +742,11 @@ export const stakingClaimConfirmationState = merge( engine: { backgroundState: { TransactionController: { - transactions: [{ type: TransactionType.stakingClaim }], + transactions: [{ + chainId: '0x1', + type: TransactionType.stakingClaim, + }], } as unknown as TransactionControllerState, - AccountsController: { - internalAccounts: { - accounts: { - '0x0000000000000000000000000000000000000000': { - address: '0x0000000000000000000000000000000000000000', - }, - }, - selectedAccount: '0x0000000000000000000000000000000000000000', - }, - }, }, }, }, diff --git a/locales/languages/en.json b/locales/languages/en.json index a5bcfb68b3c8..9ac25efd2a46 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -709,7 +709,8 @@ "circulating_supply": "Circulating supply", "all_time_high": "All time high", "all_time_low": "All time low", - "fully_diluted": "Fully diluted" + "fully_diluted": "Fully diluted", + "unknown": "Unknown" }, "collectible": { "collectible_address": "Address",