diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index d459919383d..d01115d893c 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -70,6 +70,14 @@ jest.mock('../../../../app/core/WalletConnect/WalletConnectV2', () => ({ }, })); +jest.mock('../../hooks/useMetrics/useMetrics', () => ({ + __esModule: true, + default: () => ({ + isEnabled: jest.fn().mockReturnValue(false), + getMetaMetricsId: jest.fn(), + }), +})); + import WC2ManagerMock from '../../../../app/core/WalletConnect/WalletConnectV2'; import { DevLogger as DevLoggerMock } from '../../../../app/core/SDKConnect/utils/DevLogger'; diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index e4014375227..edc43405224 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -161,6 +161,7 @@ import { SmartAccountUpdateModal } from '../../Views/confirmations/components/sm import PrivacyOverlay from '../../Views/PrivacyOverlay'; import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import { PayWithNetworkModal } from '../../Views/confirmations/components/modals/pay-with-network-modal/pay-with-network-modal'; +import { useMetrics } from '../../hooks/useMetrics'; const clearStackNavigatorOptions = { headerShown: false, @@ -922,6 +923,8 @@ const App: React.FC = () => { const sdkInit = useRef(undefined); const isFirstRender = useRef(true); + const { isEnabled: checkMetricsEnabled } = useMetrics(); + const isSeedlessOnboardingLoginFlow = useSelector( selectSeedlessOnboardingLoginFlow, ); @@ -988,7 +991,7 @@ const App: React.FC = () => { OPTIN_META_METRICS_UI_SEEN, ); - if (!isOptinMetaMetricsUISeen) { + if (!isOptinMetaMetricsUISeen && !checkMetricsEnabled()) { const resetParams = { routes: [ { diff --git a/app/components/Views/confirmations/__mocks__/controllers/transaction-controller-mock.ts b/app/components/Views/confirmations/__mocks__/controllers/transaction-controller-mock.ts index 75a02af5c15..177373da4c3 100644 --- a/app/components/Views/confirmations/__mocks__/controllers/transaction-controller-mock.ts +++ b/app/components/Views/confirmations/__mocks__/controllers/transaction-controller-mock.ts @@ -10,7 +10,7 @@ import { } from '../helpers/approve'; import { ZERO_ADDRESS } from '../../constants/address'; -const transactionIdMock = '699ca2f0-e459-11ef-b6f6-d182277cf5e1'; +export const transactionIdMock = '699ca2f0-e459-11ef-b6f6-d182277cf5e1'; const permit2TokenMock = '0x1234567890123456789012345678901234567890'; export const approvalSpenderMock = '0x9876543210987654321098765432109876543210'; diff --git a/app/components/Views/confirmations/__mocks__/send.mock.ts b/app/components/Views/confirmations/__mocks__/send.mock.ts index 580937c73ed..fe2a3f3f03a 100644 --- a/app/components/Views/confirmations/__mocks__/send.mock.ts +++ b/app/components/Views/confirmations/__mocks__/send.mock.ts @@ -84,6 +84,14 @@ export const evmSendStateMock = { }, }, }, + MultichainAssetsRatesController: { + conversionRates: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + rate: '175', + conversionTime: 0, + }, + }, + }, }, }, } as ProviderValues['state']; diff --git a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts index 78c31db9d08..70990fba6a8 100755 --- a/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts +++ b/app/components/Views/confirmations/components/UI/info-row/alert-row/constants.ts @@ -1,4 +1,5 @@ export enum RowAlertKey { + Amount = 'amount', AccountTypeUpgrade = 'accountTypeUpgrade', Blockaid = 'blockaid', EstimatedFee = 'estimatedFee', diff --git a/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.test.tsx b/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.test.tsx index f8c1f45cb8e..66c754f2d83 100644 --- a/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.test.tsx +++ b/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.test.tsx @@ -10,6 +10,7 @@ import { deflate } from 'react-native-gzip'; import { BLOCKAID_SUPPORTED_NETWORK_NAMES } from '../../../../../util/networks'; import BlockaidVersionInfo from '../../../../../lib/ppom/blockaid-version'; import { ResultType as BlockaidResultType } from '../../constants/signatures'; +import { strings } from '../../../../../../locales/i18n'; jest.mock('react-native-gzip', () => ({ deflate: jest.fn().mockResolvedValue('compressedData'), @@ -161,4 +162,23 @@ describe('BlockaidAlertContent', () => { expect(deflate).not.toHaveBeenCalled(); }); }); + + it('renders generic reason message if reason not recognised', () => { + const mockSecurityAlertResponseWithUnknownReason: SecurityAlertResponse = { + ...mockSecurityAlertResponse, + reason: 'unknown_reason' as Reason, + }; + + const { getByText } = render( + , + ); + + expect( + getByText(strings('blockaid_banner.other_description')), + ).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.tsx b/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.tsx index aab08db2bcb..effc7c8f9ac 100644 --- a/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.tsx +++ b/app/components/Views/confirmations/components/blockaid-alert-content/blockaid-alert-content.tsx @@ -93,7 +93,7 @@ const BlockaidAlertContent: React.FC = ({ {strings( REASON_DESCRIPTION_I18N_KEY_MAP[ securityAlertResponse.reason as Reason - ], + ] ?? 'blockaid_banner.other_description', )} ({ useEditNonce: jest.fn().mockReturnValue({}), @@ -138,6 +139,10 @@ jest.mock('react-native-gzip', () => ({ deflate: (str: string) => str, })); +jest.mock('../../../../UI/Bridge/hooks/useTokensWithBalance', () => ({ + useTokensWithBalance: () => [] as ReturnType, +})); + describe('Confirm', () => { afterEach(() => { jest.restoreAllMocks(); diff --git a/app/components/Views/confirmations/components/edit-amount/edit-amount.styles.ts b/app/components/Views/confirmations/components/edit-amount/edit-amount.styles.ts index a0970bbe091..54d82ef0e71 100644 --- a/app/components/Views/confirmations/components/edit-amount/edit-amount.styles.ts +++ b/app/components/Views/confirmations/components/edit-amount/edit-amount.styles.ts @@ -1,16 +1,25 @@ import { StyleSheet } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; -const styleSheet = (_params: { theme: Theme }) => +const styleSheet = (params: { theme: Theme; vars: { hasAlert: boolean } }) => StyleSheet.create({ container: { - paddingTop: 40, - paddingBottom: 40, + display: 'flex', + gap: 8, + marginTop: 16, + marginBottom: 16, }, input: { textAlign: 'center', fontSize: 64, fontWeight: '500', + color: params.vars.hasAlert + ? params.theme.colors.error.default + : params.theme.colors.text.default, + }, + alert: { + color: params.theme.colors.error.default, + textAlign: 'center', }, }); diff --git a/app/components/Views/confirmations/components/edit-amount/edit-amount.test.tsx b/app/components/Views/confirmations/components/edit-amount/edit-amount.test.tsx index d058572e980..1cb3bb39d97 100644 --- a/app/components/Views/confirmations/components/edit-amount/edit-amount.test.tsx +++ b/app/components/Views/confirmations/components/edit-amount/edit-amount.test.tsx @@ -8,12 +8,19 @@ import { useTokenAmount } from '../../hooks/useTokenAmount'; import { useTokenAsset } from '../../hooks/useTokenAsset'; import { TokenI } from '../../../../UI/Tokens/types'; import { act, fireEvent } from '@testing-library/react-native'; +import { + AlertsContextParams, + useAlerts, +} from '../../context/alert-system-context'; +import { RowAlertKey } from '../UI/info-row/alert-row/constants'; jest.mock('../../hooks/useTokenAmount'); jest.mock('../../hooks/useTokenAsset'); +jest.mock('../../context/alert-system-context'); const VALUE_MOCK = '1.23'; const VALUE_2_MOCK = '2.34'; +const ALERT_MESSAGE_MOCK = 'Test Message'; const state = merge( simpleSendTransactionControllerMock, @@ -27,6 +34,7 @@ function render(props: EditAmountProps = {}) { describe('EditAmount', () => { const useTokenAmountMock = jest.mocked(useTokenAmount); const useTokenAssetMock = jest.mocked(useTokenAsset); + const useAlertsMock = jest.mocked(useAlerts); const updateTokenAmountMock = jest.fn(); beforeEach(() => { @@ -43,6 +51,10 @@ describe('EditAmount', () => { } as TokenI, displayName: 'Test Token', }); + + useAlertsMock.mockReturnValue({ + fieldAlerts: [], + } as unknown as AlertsContextParams); }); it('renders amount from current transaction data', () => { @@ -79,4 +91,34 @@ describe('EditAmount', () => { expect(getByTestId('edit-amount-input')).toHaveProp('value', VALUE_2_MOCK); }); + + it('renders alert if field is amount', () => { + useAlertsMock.mockReturnValue({ + fieldAlerts: [ + { + field: RowAlertKey.Amount, + message: ALERT_MESSAGE_MOCK, + }, + ], + } as unknown as AlertsContextParams); + + const { getByText } = render(); + + expect(getByText(ALERT_MESSAGE_MOCK)).toBeDefined(); + }); + + it('does not render alert if field is not amount', () => { + useAlertsMock.mockReturnValue({ + fieldAlerts: [ + { + field: RowAlertKey.AccountTypeUpgrade, + message: ALERT_MESSAGE_MOCK, + }, + ], + } as unknown as AlertsContextParams); + + const { queryByText } = render(); + + expect(queryByText(ALERT_MESSAGE_MOCK)).toBeNull(); + }); }); diff --git a/app/components/Views/confirmations/components/edit-amount/edit-amount.tsx b/app/components/Views/confirmations/components/edit-amount/edit-amount.tsx index 82a6212e275..47e0394c149 100644 --- a/app/components/Views/confirmations/components/edit-amount/edit-amount.tsx +++ b/app/components/Views/confirmations/components/edit-amount/edit-amount.tsx @@ -3,13 +3,24 @@ import { TextInput, View } from 'react-native'; import { useTokenAmount } from '../../hooks/useTokenAmount'; import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './edit-amount.styles'; +import { useAlerts } from '../../context/alert-system-context'; +import Text from '../../../../../component-library/components/Texts/Text'; +import { RowAlertKey } from '../UI/info-row/alert-row/constants'; export interface EditAmountProps { + children?: React.ReactNode; prefix?: string; } -export function EditAmount({ prefix = '' }: EditAmountProps) { - const { styles } = useStyles(styleSheet, {}); +export function EditAmount({ children, prefix = '' }: EditAmountProps) { + const { fieldAlerts } = useAlerts(); + const alerts = fieldAlerts.filter((a) => a.field === RowAlertKey.Amount); + const hasAlert = alerts.length > 0; + const alertMessage = alerts[0]?.message; + + const { styles } = useStyles(styleSheet, { + hasAlert, + }); const { amountPrecise: transactionAmountHuman, updateTokenAmount } = useTokenAmount(); @@ -38,6 +49,8 @@ export function EditAmount({ prefix = '' }: EditAmountProps) { keyboardType="numeric" style={styles.input} /> + {children} + {hasAlert ? {alertMessage} : null} ); } diff --git a/app/components/Views/confirmations/components/pay-token-balance/index.ts b/app/components/Views/confirmations/components/pay-token-balance/index.ts new file mode 100644 index 00000000000..89f42f83d54 --- /dev/null +++ b/app/components/Views/confirmations/components/pay-token-balance/index.ts @@ -0,0 +1 @@ +export * from './pay-token-balance'; diff --git a/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.styles.ts b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.styles.ts new file mode 100644 index 00000000000..fb8bd7411ad --- /dev/null +++ b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.styles.ts @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + container: {}, + text: { + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.test.tsx b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.test.tsx new file mode 100644 index 00000000000..2793cb5d48e --- /dev/null +++ b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { useTokensWithBalance } from '../../../../UI/Bridge/hooks/useTokensWithBalance'; +import { BridgeToken } from '../../../../UI/Bridge/types'; +import { useTransactionPayToken } from '../../hooks/pay/useTransactionPayToken'; +import { PayTokenBalance } from './pay-token-balance'; + +jest.mock('../../hooks/pay/useTransactionPayToken'); +jest.mock('../../../../UI/Bridge/hooks/useTokensWithBalance'); + +const TOKEN_ADDRESS_MOCK = '0xabcd1234abcd1234abcd1234abcd1234abcd1234'; +const CHAIN_ID_MOCK = '0x123'; +const BALANCE_FIAT_MOCK = '$100.12'; + +describe('PayTokenBalance', () => { + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useTokensWithBalanceMock = jest.mocked(useTokensWithBalance); + + beforeEach(() => { + jest.resetAllMocks(); + + useTransactionPayTokenMock.mockReturnValue({ + balanceHuman: '1.23', + decimals: 4, + payToken: { + address: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }, + setPayToken: jest.fn(), + }); + + useTokensWithBalanceMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + balanceFiat: BALANCE_FIAT_MOCK, + }, + ] as unknown as BridgeToken[]); + }); + + it('renders pay token balance', () => { + const { getByText } = render(); + expect(getByText(`Available: ${BALANCE_FIAT_MOCK}`)).toBeTruthy(); + }); +}); diff --git a/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.tsx b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.tsx new file mode 100644 index 00000000000..e4cdb806fb6 --- /dev/null +++ b/app/components/Views/confirmations/components/pay-token-balance/pay-token-balance.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { View } from 'react-native'; +import Text, { + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './pay-token-balance.styles'; +import { useTokensWithBalance } from '../../../../UI/Bridge/hooks/useTokensWithBalance'; +import { useTransactionPayToken } from '../../hooks/pay/useTransactionPayToken'; +import { strings } from '../../../../../../locales/i18n'; + +export function PayTokenBalance() { + const { styles } = useStyles(styleSheet, {}); + const { payToken } = useTransactionPayToken(); + const { address: payTokenAddress, chainId } = payToken; + const tokens = useTokensWithBalance({ chainIds: [chainId] }); + + const token = tokens.find( + (t) => + t.address.toLowerCase() === payTokenAddress.toLowerCase() && + t.chainId === chainId, + ); + + if (!token) { + return null; + } + + return ( + + + {strings('confirm.available_balance')} + {token.balanceFiat} + + + ); +} diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx index 28ae7595d18..1206bee0b0b 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx @@ -51,6 +51,7 @@ describe('PayWithRow', () => { jest.resetAllMocks(); jest.mocked(useTransactionPayToken).mockReturnValue({ + balanceHuman: '1.23', decimals: 18, payToken: { address: ADDRESS_MOCK, diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx index 719afd837f9..49d756d35bb 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -39,7 +39,7 @@ export function PayWithRow() { {showEstimate && ( {loading ? ( - + ) : ( {estimatedTimeSeconds} {strings('unit.second')} diff --git a/app/components/Views/confirmations/components/rows/total-row/index.ts b/app/components/Views/confirmations/components/rows/total-row/index.ts new file mode 100644 index 00000000000..33038673ed3 --- /dev/null +++ b/app/components/Views/confirmations/components/rows/total-row/index.ts @@ -0,0 +1 @@ +export * from './total-row'; diff --git a/app/components/Views/confirmations/components/rows/total-row/total-row.test.tsx b/app/components/Views/confirmations/components/rows/total-row/total-row.test.tsx new file mode 100644 index 00000000000..d44fd2609f3 --- /dev/null +++ b/app/components/Views/confirmations/components/rows/total-row/total-row.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useTransactionTotalFiat } from '../../../hooks/pay/useTransactionTotalFiat'; +import { TotalRow } from './total-row'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { merge } from 'lodash'; +import { + simpleSendTransactionControllerMock, + transactionIdMock, +} from '../../../__mocks__/controllers/transaction-controller-mock'; +import { ConfirmationMetricsState } from '../../../../../../core/redux/slices/confirmationMetrics'; +import { transactionApprovalControllerMock } from '../../../__mocks__/controllers/approval-controller-mock'; +import { View as MockView } from 'react-native'; + +jest.mock('../../../hooks/pay/useTransactionTotalFiat'); + +jest.mock('../../../../../UI/AnimatedSpinner', () => ({ + __esModule: true, + ...jest.requireActual('../../../../../UI/AnimatedSpinner'), + default: () => {`Spinner`}, +})); + +const TOTAL_FIAT_MOCK = '$123.456'; + +function render({ isLoading }: { isLoading?: boolean } = {}) { + return renderWithProvider(, { + state: merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + { + confirmationMetrics: { + isTransactionBridgeQuotesLoadingById: { + [transactionIdMock]: isLoading, + }, + } as unknown as ConfirmationMetricsState, + }, + ), + }); +} + +describe('TotalRow', () => { + const useTransactionTotalFiatMock = jest.mocked(useTransactionTotalFiat); + + beforeEach(() => { + jest.clearAllMocks(); + + useTransactionTotalFiatMock.mockReturnValue({ + value: 123.456, + formatted: TOTAL_FIAT_MOCK, + }); + }); + + it('renders the total amount', () => { + const { getByText } = render(); + expect(getByText(TOTAL_FIAT_MOCK)).toBeDefined(); + }); + + it('renders a spinner when quotes are loading', () => { + const { getByTestId } = render({ isLoading: true }); + expect(getByTestId('total-spinner')).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/rows/total-row/total-row.tsx b/app/components/Views/confirmations/components/rows/total-row/total-row.tsx new file mode 100644 index 00000000000..a01e84446a0 --- /dev/null +++ b/app/components/Views/confirmations/components/rows/total-row/total-row.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Text from '../../../../../../component-library/components/Texts/Text'; +import InfoRow from '../../UI/info-row'; +import InfoSection from '../../UI/info-row/info-section'; +import { useTransactionTotalFiat } from '../../../hooks/pay/useTransactionTotalFiat'; +import { strings } from '../../../../../../../locales/i18n'; +import { useTransactionMetadataOrThrow } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { useSelector } from 'react-redux'; +import { selectIsTransactionBridgeQuotesLoadingById } from '../../../../../../core/redux/slices/confirmationMetrics'; +import { RootState } from '../../../../../../reducers'; +import AnimatedSpinner, { + SpinnerSize, +} from '../../../../../UI/AnimatedSpinner'; + +export function TotalRow() { + const { id: transactionId } = useTransactionMetadataOrThrow(); + const { formatted: totalFiat } = useTransactionTotalFiat(); + + const isQuotesLoading = useSelector((state: RootState) => + selectIsTransactionBridgeQuotesLoadingById(state, transactionId), + ); + + return ( + + + {isQuotesLoading ? ( + + ) : ( + {totalFiat} + )} + + + ); +} diff --git a/app/components/Views/confirmations/components/send/amount/amount.test.tsx b/app/components/Views/confirmations/components/send/amount/amount.test.tsx index 2096e23b2c5..d12bcd3db55 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.test.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount.test.tsx @@ -9,6 +9,7 @@ import renderWithProvider, { import { SendContextProvider } from '../../../context/send-context'; import { ACCOUNT_ADDRESS_MOCK_1, + SOLANA_ASSET, TOKEN_ADDRESS_MOCK_1, evmSendStateMock, } from '../../../__mocks__/send.mock'; @@ -91,6 +92,22 @@ describe('Amount', () => { expect(getByText('Invalid amount')).toBeTruthy(); }); + it('pressing Max uses max balance of ERC20 token', () => { + (useRoute as jest.MockedFn).mockReturnValue({ + params: { + asset: { + address: TOKEN_ADDRESS_MOCK_1, + decimals: 2, + }, + }, + } as RouteProp); + + const { getByText, getByTestId } = renderComponent(); + expect(getByTestId('send_amount').props.value).toBe(''); + fireEvent.press(getByText('Max')); + expect(getByTestId('send_amount').props.value).toBe('0.05'); + }); + it('display error if amount is greater than balance for native token', async () => { (useRoute as jest.MockedFn).mockReturnValue({ params: { @@ -134,23 +151,6 @@ describe('Amount', () => { expect(getByText(`Asset: ${TOKEN_ADDRESS_MOCK_1}`)).toBeTruthy(); }); - it('pressing Max uses max balance of ERC20 token', () => { - (useRoute as jest.MockedFn).mockReturnValue({ - params: { - asset: { - address: TOKEN_ADDRESS_MOCK_1, - decimals: 2, - }, - }, - } as RouteProp); - - const { getByText, getByTestId } = renderComponent(); - expect(getByTestId('send_amount').props.value).toBe(''); - fireEvent.press(getByText('Max')); - expect(getByTestId('send_amount').props.value).toBe('0.05'); - expect(getByText('$ 0.05')).toBeTruthy(); - }); - it('pressing Max uses max balance minus gas for native token', () => { (useRoute as jest.MockedFn).mockReturnValue({ params: { @@ -187,6 +187,18 @@ describe('Amount', () => { expect(getByText('$ 3890')).toBeTruthy(); }); + it('display fiat conversion of amount entered for solana asset', async () => { + (useRoute as jest.MockedFn).mockReturnValue({ + params: { + asset: SOLANA_ASSET, + }, + } as RouteProp); + + const { getByText, getByTestId } = renderComponent(); + fireEvent.changeText(getByTestId('send_amount'), '1'); + expect(getByText('$ 175')).toBeTruthy(); + }); + it('if fiatmode is enabled display native conversion of amount entered', async () => { (useRoute as jest.MockedFn).mockReturnValue({ params: { diff --git a/app/components/Views/confirmations/components/token-amount-native/index.ts b/app/components/Views/confirmations/components/token-amount-native/index.ts new file mode 100644 index 00000000000..6913b964335 --- /dev/null +++ b/app/components/Views/confirmations/components/token-amount-native/index.ts @@ -0,0 +1 @@ +export * from './token-amount-native'; diff --git a/app/components/Views/confirmations/components/token-amount-native/token-amount-native.styles.ts b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.styles.ts new file mode 100644 index 00000000000..e0464303f63 --- /dev/null +++ b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.styles.ts @@ -0,0 +1,20 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + container: { + alignItems: 'center', + alignSelf: 'center', + backgroundColor: params.theme.colors.background.subsection, + borderRadius: 99, + display: 'flex', + flexDirection: 'row', + gap: 4, + paddingInline: 16, + paddingVertical: 6, + marginBottom: 32, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/confirmations/components/token-amount-native/token-amount-native.test.tsx b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.test.tsx new file mode 100644 index 00000000000..92dfb927114 --- /dev/null +++ b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { merge } from 'lodash'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { TokenAmountNative } from './token-amount-native'; +import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { useTokenAmount } from '../../hooks/useTokenAmount'; +import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock'; + +jest.mock('../../hooks/useTokenAmount'); + +function render() { + return renderWithProvider(, { + state: merge( + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ), + }); +} + +const NATIVE_VALUE_MOCK = '123.123456'; + +describe('TokenAmountNative', () => { + const useTokenAmountMock = jest.mocked(useTokenAmount); + + beforeEach(() => { + jest.resetAllMocks(); + + useTokenAmountMock.mockReturnValue({ + amountNative: NATIVE_VALUE_MOCK, + } as unknown as ReturnType); + }); + + it('renders native value', () => { + const { getByText } = render(); + expect(getByText(NATIVE_VALUE_MOCK, { exact: false })).toBeDefined(); + }); + + it('renders ticker', () => { + const { getByText } = render(); + expect(getByText('ETH', { exact: false })).toBeDefined(); + }); +}); diff --git a/app/components/Views/confirmations/components/token-amount-native/token-amount-native.tsx b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.tsx new file mode 100644 index 00000000000..96a25cf11b2 --- /dev/null +++ b/app/components/Views/confirmations/components/token-amount-native/token-amount-native.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { useTokenAmount } from '../../hooks/useTokenAmount'; +import { useTransactionMetadataOrThrow } from '../../hooks/transactions/useTransactionMetadataRequest'; +import { View } from 'react-native'; +import Text from '../../../../../component-library/components/Texts/Text'; +import { selectTickerByChainId } from '../../../../../selectors/networkController'; +import { RootState } from '../../../../../reducers'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './token-amount-native.styles'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; + +export function TokenAmountNative() { + const { styles } = useStyles(styleSheet, {}); + const { amountNative } = useTokenAmount(); + const { chainId } = useTransactionMetadataOrThrow(); + + const ticker = useSelector((state: RootState) => + selectTickerByChainId(state, chainId), + ); + + return ( + + + {amountNative} {ticker} + + + + ); +} diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index 8fa9a943916..7f94595cd1d 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -5,4 +5,6 @@ export enum AlertKeys { PendingTransaction = 'pending_transaction', SignedOrSubmitted = 'signed_or_submitted', BatchedUnusedApprovals = 'batched_unused_approvals', + PerpsDepositMinimum = 'perps_deposit_minimum', + InsufficientPayTokenBalance = 'insufficient_pay_token_balance', } diff --git a/app/components/Views/confirmations/external/perps-temp/components/deposit/deposit.tsx b/app/components/Views/confirmations/external/perps-temp/components/deposit/deposit.tsx index af72d6ff3e5..76bf358b118 100644 --- a/app/components/Views/confirmations/external/perps-temp/components/deposit/deposit.tsx +++ b/app/components/Views/confirmations/external/perps-temp/components/deposit/deposit.tsx @@ -5,6 +5,9 @@ import { PayWithRow } from '../../../../components/rows/pay-with-row'; import useNavbar from '../../../../hooks/ui/useNavbar'; import { EditAmount } from '../../../../components/edit-amount'; import { strings } from '../../../../../../../../locales/i18n'; +import { PayTokenBalance } from '../../../../components/pay-token-balance'; +import { TokenAmountNative } from '../../../../components/token-amount-native'; +import { TotalRow } from '../../../../components/rows/total-row'; const AMOUNT_PREFIX = '$'; @@ -13,9 +16,13 @@ export function PerpsDeposit() { return ( - + + + + + ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index a6d1dbf5cbe..1613927312b 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -13,6 +13,8 @@ import { useAccountTypeUpgrade } from './useAccountTypeUpgrade'; import { useBatchedUnusedApprovalsAlert } from './useBatchedUnusedApprovalsAlert'; import { useSignedOrSubmittedAlert } from './useSignedOrSubmittedAlert'; import { usePendingTransactionAlert } from './usePendingTransactionAlert'; +import { usePerpsDepositMinimumAlert } from './usePerpsDepositMinimumAlert'; +import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBalanceAlert'; jest.mock('./useBlockaidAlerts'); jest.mock('./useDomainMismatchAlerts'); @@ -21,6 +23,8 @@ jest.mock('./useAccountTypeUpgrade'); jest.mock('./useSignedOrSubmittedAlert'); jest.mock('./usePendingTransactionAlert'); jest.mock('./useBatchedUnusedApprovalsAlert'); +jest.mock('./usePerpsDepositMinimumAlert'); +jest.mock('./useInsufficientPayTokenBalanceAlert'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -90,6 +94,24 @@ describe('useConfirmationAlerts', () => { }, ]; + const mockPerpsDepositMinimumAlert: Alert[] = [ + { + key: 'PerpsDepositMinimumAlert', + title: 'Test Perps Deposit Minimum Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Warning, + }, + ]; + + const mockInsufficientPayTokenBalanceAlert: Alert[] = [ + { + key: 'InsufficientPayTokenBalance', + title: 'Test Insufficient Pay Token Balance Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Danger, + }, + ]; + beforeEach(() => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); @@ -99,6 +121,8 @@ describe('useConfirmationAlerts', () => { (useSignedOrSubmittedAlert as jest.Mock).mockReturnValue([]); (usePendingTransactionAlert as jest.Mock).mockReturnValue([]); (useBatchedUnusedApprovalsAlert as jest.Mock).mockReturnValue([]); + (usePerpsDepositMinimumAlert as jest.Mock).mockReturnValue([]); + (useInsufficientPayTokenBalanceAlert as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -154,6 +178,12 @@ describe('useConfirmationAlerts', () => { (useBatchedUnusedApprovalsAlert as jest.Mock).mockReturnValue( mockBatchedUnusedApprovalsAlert, ); + (usePerpsDepositMinimumAlert as jest.Mock).mockReturnValue( + mockPerpsDepositMinimumAlert, + ); + (useInsufficientPayTokenBalanceAlert as jest.Mock).mockReturnValue( + mockInsufficientPayTokenBalanceAlert, + ); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -164,6 +194,8 @@ describe('useConfirmationAlerts', () => { ...mockBatchedUnusedApprovalsAlert, ...mockPendingTransactionAlert, ...mockSignedOrSubmittedAlert, + ...mockPerpsDepositMinimumAlert, + ...mockInsufficientPayTokenBalanceAlert, ...mockUpgradeAccountAlert, ]); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index e26432dc6d0..1f985c3b052 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -7,6 +7,8 @@ import { useSignedOrSubmittedAlert } from './useSignedOrSubmittedAlert'; import { usePendingTransactionAlert } from './usePendingTransactionAlert'; import { Alert } from '../../types/alerts'; import { useBatchedUnusedApprovalsAlert } from './useBatchedUnusedApprovalsAlert'; +import { usePerpsDepositMinimumAlert } from './usePerpsDepositMinimumAlert'; +import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBalanceAlert'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -19,6 +21,9 @@ function useTransactionAlerts(): Alert[] { const signedOrSubmittedAlert = useSignedOrSubmittedAlert(); const pendingTransactionAlert = usePendingTransactionAlert(); const batchedUnusedApprovalsAlert = useBatchedUnusedApprovalsAlert(); + const perpsDepositMinimumAlert = usePerpsDepositMinimumAlert(); + const insufficientPayTokenBalanceAlert = + useInsufficientPayTokenBalanceAlert(); return useMemo( () => [ @@ -26,12 +31,16 @@ function useTransactionAlerts(): Alert[] { ...batchedUnusedApprovalsAlert, ...pendingTransactionAlert, ...signedOrSubmittedAlert, + ...perpsDepositMinimumAlert, + ...insufficientPayTokenBalanceAlert, ], [ insufficientBalanceAlert, batchedUnusedApprovalsAlert, pendingTransactionAlert, signedOrSubmittedAlert, + perpsDepositMinimumAlert, + insufficientPayTokenBalanceAlert, ], ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts new file mode 100644 index 00000000000..62b488bb7a9 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react-native'; +import { useTransactionPayToken } from '../pay/useTransactionPayToken'; +import { useTransactionPayTokenAmounts } from '../pay/useTransactionPayTokenAmounts'; +import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBalanceAlert'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { Severity } from '../../types/alerts'; +import { strings } from '../../../../../../locales/i18n'; + +jest.mock('../pay/useTransactionPayToken'); +jest.mock('../pay/useTransactionPayTokenAmounts'); + +function runHook() { + return renderHook(() => useInsufficientPayTokenBalanceAlert()); +} + +describe('useInsufficientPayTokenBalance', () => { + const useTransactionPayTokenAmountsMock = jest.mocked( + useTransactionPayTokenAmounts, + ); + + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns alert if balance less than total', () => { + useTransactionPayTokenAmountsMock.mockReturnValue({ + totalHuman: '123.456', + } as ReturnType); + + useTransactionPayTokenMock.mockReturnValue({ + balanceHuman: '123.455', + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toEqual([ + { + key: AlertKeys.InsufficientPayTokenBalance, + field: RowAlertKey.Amount, + message: strings('alert_system.insufficient_pay_token_balance.message'), + severity: Severity.Danger, + isBlocking: true, + }, + ]); + }); + + it('returns no alerts if balance is sufficient', () => { + useTransactionPayTokenAmountsMock.mockReturnValue({ + totalHuman: '123.456', + } as ReturnType); + + useTransactionPayTokenMock.mockReturnValue({ + balanceHuman: '123.456', + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toEqual([]); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts new file mode 100644 index 00000000000..62d0716a32f --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { Alert, Severity } from '../../types/alerts'; +import { useTransactionPayToken } from '../pay/useTransactionPayToken'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { BigNumber } from 'bignumber.js'; +import { useTransactionPayTokenAmounts } from '../pay/useTransactionPayTokenAmounts'; +import { strings } from '../../../../../../locales/i18n'; + +export function useInsufficientPayTokenBalanceAlert(): Alert[] { + const { totalHuman } = useTransactionPayTokenAmounts(); + const { balanceHuman } = useTransactionPayToken(); + + const isInsufficient = + new BigNumber(balanceHuman ?? '0').isLessThan( + new BigNumber(totalHuman ?? '0'), + ) && process.env.MM_CONFIRMATION_INTENTS === 'true'; + + return useMemo(() => { + if (!isInsufficient) { + return []; + } + + return [ + { + key: AlertKeys.InsufficientPayTokenBalance, + field: RowAlertKey.Amount, + message: strings('alert_system.insufficient_pay_token_balance.message'), + severity: Severity.Danger, + isBlocking: true, + }, + ]; + }, [isInsufficient]); +} diff --git a/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.test.ts new file mode 100644 index 00000000000..134cb45fc96 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.test.ts @@ -0,0 +1,49 @@ +import { renderHook } from '@testing-library/react-native'; +import { usePerpsDepositMinimumAlert } from './usePerpsDepositMinimumAlert'; +import { useTokenAmount } from '../useTokenAmount'; +import { AlertKeys } from '../../constants/alerts'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { Severity } from '../../types/alerts'; +import { strings } from '../../../../../../locales/i18n'; + +jest.mock('../useTokenAmount'); + +function runHook() { + return renderHook(() => usePerpsDepositMinimumAlert()); +} + +describe('usePerpsDepositMinimumAlert', () => { + const useTokenAmountMock = jest.mocked(useTokenAmount); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns alert if token amount less than minimum', () => { + useTokenAmountMock.mockReturnValue({ usdValue: '9.99' } as ReturnType< + typeof useTokenAmount + >); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([ + { + key: AlertKeys.PerpsDepositMinimum, + field: RowAlertKey.Amount, + message: strings('alert_system.perps_deposit_minimum.message'), + severity: Severity.Danger, + isBlocking: true, + }, + ]); + }); + + it('returns no alert if token amount greater than minimum', () => { + useTokenAmountMock.mockReturnValue({ usdValue: '10.01' } as ReturnType< + typeof useTokenAmount + >); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.ts b/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.ts new file mode 100644 index 00000000000..94d6abac423 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/usePerpsDepositMinimumAlert.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { useTokenAmount } from '../useTokenAmount'; +import { AlertKeys } from '../../constants/alerts'; +import { Alert, Severity } from '../../types/alerts'; +import { BigNumber } from 'bignumber.js'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { strings } from '../../../../../../locales/i18n'; + +export const MINIMUM_DEPOSIT_USD = 10; + +export function usePerpsDepositMinimumAlert(): Alert[] { + const { usdValue } = useTokenAmount(); + + const underMinimum = + new BigNumber(usdValue ?? '0').isLessThan(MINIMUM_DEPOSIT_USD) && + process.env.MM_CONFIRMATION_INTENTS === 'true'; + + return useMemo(() => { + if (!underMinimum) { + return []; + } + + return [ + { + key: AlertKeys.PerpsDepositMinimum, + field: RowAlertKey.Amount, + message: strings('alert_system.perps_deposit_minimum.message'), + severity: Severity.Danger, + isBlocking: true, + }, + ]; + }, [underMinimum]); +} diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 84abb21776b..edeb654e84b 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -112,6 +112,8 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', [AlertKeys.PendingTransaction]: 'pending_transaction', [AlertKeys.BatchedUnusedApprovals]: 'batched_unused_approvals', + [AlertKeys.PerpsDepositMinimum]: 'perps_deposit_minimum', + [AlertKeys.InsufficientPayTokenBalance]: 'insufficient_pay_token_balance', }; function getAlertName(alertKey: string): string { diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.test.ts index a1db7d48794..dc86536111b 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.test.ts @@ -66,6 +66,7 @@ describe('useTransactionBridgeQuotes', () => { } as unknown as TransactionMeta); useTransactionPayTokenMock.mockReturnValue({ + balanceHuman: '123.456', decimals: 4, payToken: { address: TOKEN_ADDRESS_SOURCE_MOCK, @@ -83,10 +84,12 @@ describe('useTransactionBridgeQuotes', () => { }, ] as unknown as TransactionToken[]); - useTransactionPayTokenAmountsMock.mockReturnValue([ - SOURCE_AMOUNT_1_MOCK, - SOURCE_AMOUNT_2_MOCK, - ]); + useTransactionPayTokenAmountsMock.mockReturnValue({ + amounts: [ + { amountRaw: SOURCE_AMOUNT_1_MOCK }, + { amountRaw: SOURCE_AMOUNT_2_MOCK }, + ], + } as ReturnType); getBridgeQuotesMock.mockResolvedValue([QUOTE_MOCK, QUOTE_MOCK]); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts index e1ef81c0f9c..dac48c9fd38 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts @@ -5,7 +5,10 @@ import { useEffect, useMemo } from 'react'; import { useTransactionPayToken } from './useTransactionPayToken'; import { useTransactionRequiredTokens } from './useTransactionRequiredTokens'; import { useDispatch } from 'react-redux'; -import { setTransactionBridgeQuotes } from '../../../../../core/redux/slices/confirmationMetrics'; +import { + setTransactionBridgeQuotes, + setTransactionBridgeQuotesLoading, +} from '../../../../../core/redux/slices/confirmationMetrics'; import { useTransactionMetadataOrThrow } from '../transactions/useTransactionMetadataRequest'; import { Hex, createProjectLogger } from '@metamask/utils'; @@ -25,17 +28,14 @@ export function useTransactionBridgeQuotes() { payToken: { address: sourceTokenAddress, chainId: sourceChainId }, } = useTransactionPayToken(); - const sourceAmounts = useTransactionPayTokenAmounts(); + const { amounts: sourceAmounts } = useTransactionPayTokenAmounts(); const requiredTokens = useTransactionRequiredTokens(); const requests: (BridgeQuoteRequest | undefined)[] = useMemo( () => - sourceAmounts?.map((sourceTokenAmount, index) => { + sourceAmounts?.map((sourceAmount, index) => { const { address: targetTokenAddress } = requiredTokens[index] || {}; - - if (!sourceTokenAmount) { - return undefined; - } + const { amountRaw: sourceTokenAmount } = sourceAmount; return { from: from as Hex, @@ -64,6 +64,12 @@ export function useTransactionBridgeQuotes() { return getBridgeQuotes(requests as BridgeQuoteRequest[]); }, [requests]); + useEffect(() => { + dispatch( + setTransactionBridgeQuotesLoading({ transactionId, isLoading: loading }), + ); + }, [dispatch, transactionId, loading]); + useEffect(() => { dispatch(setTransactionBridgeQuotes({ transactionId, quotes })); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.test.ts index 07cb9f55d1b..540ed11913a 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.test.ts @@ -12,6 +12,10 @@ import { otherControllersMock, tokenAddress1Mock, } from '../../__mocks__/controllers/other-controllers-mock'; +import { useTokensWithBalance } from '../../../../UI/Bridge/hooks/useTokensWithBalance'; +import { BridgeToken } from '../../../../UI/Bridge/types'; + +jest.mock('../../../../UI/Bridge/hooks/useTokensWithBalance'); const STATE_MOCK = merge( simpleSendTransactionControllerMock, @@ -47,6 +51,21 @@ function runHook({ } describe('useTransactionPayToken', () => { + const useTokensWithBalanceMock = jest.mocked(useTokensWithBalance); + + beforeEach(() => { + jest.resetAllMocks(); + + useTokensWithBalanceMock.mockReturnValue([ + { + address: tokenAddress1Mock, + balance: '123.456', + decimals: 4, + chainId: ChainId.mainnet, + }, + ] as unknown as BridgeToken[]); + }); + it('returns default token if no state', () => { const { result } = runHook(); @@ -72,6 +91,14 @@ describe('useTransactionPayToken', () => { expect(result.current.decimals).toEqual(4); }); + it('returns balance', () => { + const { result } = runHook({ + payToken: PAY_TOKEN_MOCK, + }); + + expect(result.current.balanceHuman).toEqual('123.456'); + }); + it('sets token in state', () => { const setPayTokenActionMock = jest.spyOn( ConfirmationMetricsReducer, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts index 8dc0e1364f1..e84aa59382b 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts @@ -9,7 +9,7 @@ import { EMPTY_ADDRESS } from '../../../../../constants/transaction'; import { useCallback } from 'react'; import { RootState } from '../../../../../reducers'; import { Hex } from '@metamask/utils'; -import { selectTokensByChainIdAndAddress } from '../../../../../selectors/tokensController'; +import { useTokensWithBalance } from '../../../../UI/Bridge/hooks/useTokensWithBalance'; export function useTransactionPayToken() { const dispatch = useDispatch(); @@ -22,13 +22,12 @@ export function useTransactionPayToken() { ); const chainId = selectedPayToken?.chainId || transactionChainId; + const tokens = useTokensWithBalance({ chainIds: [chainId] }); - const chainTokens = Object.values( - useSelector((state) => selectTokensByChainIdAndAddress(state, chainId)), - ); - - const token = chainTokens.find( - (t) => t.address.toLowerCase() === selectedPayToken?.address.toLowerCase(), + const token = tokens.find( + (t) => + t.chainId === chainId && + t.address.toLowerCase() === selectedPayToken?.address.toLowerCase(), ); const defaultPayToken: TransactionPayToken = { @@ -37,6 +36,7 @@ export function useTransactionPayToken() { }; const decimals = token?.decimals ?? 18; + const balanceHuman = token?.balance ?? '0'; const setPayToken = useCallback( (payToken: TransactionPayToken) => { @@ -53,6 +53,7 @@ export function useTransactionPayToken() { const payToken = selectedPayToken ?? defaultPayToken; return { + balanceHuman, decimals, payToken, setPayToken, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.test.ts index 385b245d40c..8582becb383 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.test.ts @@ -44,6 +44,7 @@ describe('useTransactionPayTokenAmounts', () => { useTokenFiatRatesMock.mockReturnValue([4]); useTransactionPayTokenMock.mockReturnValue({ + balanceHuman: '123.456', decimals: 4, payToken: { address: tokenAddress1Mock, @@ -55,13 +56,32 @@ describe('useTransactionPayTokenAmounts', () => { it('returns source amounts', () => { const sourceAmounts = runHook(); - expect(sourceAmounts).toEqual(['40308', '101140']); + + expect(sourceAmounts).toEqual( + expect.objectContaining({ + amounts: [ + { amountHuman: '4.03075', amountRaw: '40308' }, + { amountHuman: '10.114', amountRaw: '101140' }, + ], + }), + ); }); it('returns undefined if no fiat rate', () => { useTokenFiatRatesMock.mockReturnValue([]); const sourceAmounts = runHook(); - expect(sourceAmounts).toBeUndefined(); + expect(sourceAmounts.amounts).toBeUndefined(); + }); + + it('returns total amounts', () => { + const sourceAmounts = runHook(); + + expect(sourceAmounts).toEqual( + expect.objectContaining({ + totalHuman: '14.14475', + totalRaw: '141448', + }), + ); }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.ts index 8badff9c1ab..ea704e07ec4 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayTokenAmounts.ts @@ -33,29 +33,42 @@ export function useTransactionPayTokenAmounts() { return undefined; } - return values.map((value) => - calculateAmount(value.totalFiat, tokenFiatRate, decimals), - ); + return values.map((value) => { + const amountHuman = new BigNumber(value.totalFiat).div(tokenFiatRate); + const amountRaw = amountHuman.shiftedBy(decimals).toFixed(0); + + return { + amountHuman: amountHuman.toString(10), + amountRaw, + }; + }); }, [decimals, tokenFiatRate, values]); - useEffect(() => { - log('Pay token amounts', amounts); - }, [amounts]); + const totalHuman = amounts + ?.reduce( + (acc, { amountHuman }) => acc.plus(new BigNumber(amountHuman ?? '0')), + new BigNumber(0), + ) + .toString(10); - return amounts; -} + const totalRaw = amounts + ?.reduce( + (acc, { amountRaw }) => acc.plus(new BigNumber(amountRaw ?? '0')), + new BigNumber(0), + ) + .toFixed(0); -function calculateAmount( - fiatAmount: number | undefined, - fiatRate: number, - decimals: number, -) { - if (!fiatAmount) { - return undefined; - } - - const amountDecimals = new BigNumber(fiatAmount).div(fiatRate); - const amountRaw = amountDecimals.shiftedBy(decimals).toFixed(0); + useEffect(() => { + log('Pay token amounts', { + amounts, + totalHuman, + totalRaw, + }); + }, [amounts, totalHuman, totalRaw]); - return amountRaw; + return { + amounts, + totalHuman, + totalRaw, + }; } diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.test.ts new file mode 100644 index 00000000000..87185ff7fa6 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.test.ts @@ -0,0 +1,106 @@ +import { merge } from 'lodash'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useTransactionTotalFiat } from './useTransactionTotalFiat'; +import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock'; +import { useTransactionMaxGasCost } from '../gas/useTransactionMaxGasCost'; +import { useTransactionRequiredFiat } from './useTransactionRequiredFiat'; +import { useTransactionRequiredTokens } from './useTransactionRequiredTokens'; +import { toHex } from '@metamask/controller-utils'; +import { TransactionBridgeQuote } from '../../utils/bridge'; + +jest.mock('../gas/useTransactionMaxGasCost'); +jest.mock('./useTransactionRequiredFiat'); +jest.mock('./useTransactionRequiredTokens'); + +function runHook({ quotes }: { quotes?: TransactionBridgeQuote[] } = {}) { + return renderHookWithProvider(useTransactionTotalFiat, { + state: merge( + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + { + confirmationMetrics: { + transactionBridgeQuotesById: { + '699ca2f0-e459-11ef-b6f6-d182277cf5e1': quotes ?? [], + }, + }, + }, + ), + }); +} + +describe('useTransactionTotalFiat', () => { + const useTransactionMaxGasCostMock = jest.mocked(useTransactionMaxGasCost); + + const useTransactionRequiredTokensMock = jest.mocked( + useTransactionRequiredTokens, + ); + + const useTransactionRequiredFiatMock = jest.mocked( + useTransactionRequiredFiat, + ); + + beforeEach(() => { + jest.resetAllMocks(); + + useTransactionMaxGasCostMock.mockReturnValue('0x0'); + + useTransactionRequiredFiatMock.mockReturnValue({ + values: [], + totalFiat: 0, + totalWithBalanceFiat: 0, + }); + + useTransactionRequiredTokensMock.mockReturnValue([]); + }); + + it('includes gas cost', () => { + useTransactionMaxGasCostMock.mockReturnValue(toHex('12345600000000000')); + + const { result } = runHook(); + + expect(result.current).toStrictEqual({ + value: 123.456, + formatted: '$123.46', + }); + }); + + it('includes quotes cost', () => { + useTransactionRequiredFiatMock.mockReturnValue({ + values: [], + totalFiat: 0, + totalWithBalanceFiat: 456.123, + }); + + const { result } = runHook(); + + expect(result.current).toStrictEqual({ + value: 456.123, + formatted: '$456.12', + }); + }); + + it('includes quotes gas cost', () => { + const { result } = runHook({ + quotes: [ + { + totalMaxNetworkFee: { + valueInCurrency: '123.456', + }, + }, + { + totalMaxNetworkFee: { + valueInCurrency: '456.123', + }, + }, + ] as TransactionBridgeQuote[], + }); + + expect(result.current).toStrictEqual({ + value: 579.579, + formatted: '$579.58', + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.ts b/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.ts new file mode 100644 index 00000000000..83f1383ac4f --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useTransactionTotalFiat.ts @@ -0,0 +1,78 @@ +import { useSelector } from 'react-redux'; +import { useTransactionMetadataOrThrow } from '../transactions/useTransactionMetadataRequest'; +import { useTransactionRequiredTokens } from './useTransactionRequiredTokens'; +import { selectConversionRateByChainId } from '../../../../../selectors/currencyRateController'; +import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens'; +import { useTransactionMaxGasCost } from '../gas/useTransactionMaxGasCost'; +import { RootState } from '../../../../../reducers'; +import { selectTransactionBridgeQuotesById } from '../../../../../core/redux/slices/confirmationMetrics'; +import { useTransactionRequiredFiat } from './useTransactionRequiredFiat'; +import { BigNumber } from 'bignumber.js'; +import { createProjectLogger } from '@metamask/utils'; +import { useEffect } from 'react'; +import useFiatFormatter from '../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; + +const log = createProjectLogger('transaction-pay'); + +export function useTransactionTotalFiat() { + const gasCost = useGasCost(); + const quotesGasCost = useQuotesGasCost(); + const fiatFormatter = useFiatFormatter(); + const { totalWithBalanceFiat: quotesCost } = useTransactionRequiredFiat(); + + const value = gasCost + quotesGasCost + quotesCost; + const formatted = fiatFormatter(new BigNumber(value)); + + useEffect(() => { + log('Total fiat', { + gasCost, + quotesGasCost, + quotesCost, + value, + formatted, + }); + }, [gasCost, quotesGasCost, quotesCost, value, formatted]); + + return { + value, + formatted, + }; +} + +function useQuotesGasCost() { + const { id: transactionId } = useTransactionMetadataOrThrow(); + + const quotes = useSelector((state: RootState) => + selectTransactionBridgeQuotesById(state, transactionId), + ); + + return (quotes ?? []).reduce((acc, quote) => { + const value = new BigNumber(quote.totalMaxNetworkFee.valueInCurrency ?? 0); + return acc + (value.isNaN() ? 0 : value.toNumber()); + }, 0); +} + +function useGasCost() { + const tokens = useTransactionRequiredTokens(); + const { chainId } = useTransactionMetadataOrThrow(); + + const conversionRate = useSelector((state: RootState) => + selectConversionRateByChainId(state, chainId), + ); + + const nativeToken = tokens.find( + (token) => + token.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(), + ); + + const gasCost = useTransactionMaxGasCost() ?? '0x0'; + + if (nativeToken) { + return 0; + } + + return new BigNumber(gasCost, 16) + .shiftedBy(-18) + .multipliedBy(new BigNumber(conversionRate ?? 1)) + .toNumber(); +} diff --git a/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.test.ts index 51c6e0b8b2c..237b9e59a3d 100644 --- a/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.test.ts @@ -19,6 +19,9 @@ jest.mock('../../../../../../core/Engine', () => ({ AssetsContractController: { getERC721AssetSymbol: Promise.resolve(undefined), }, + NetworkController: { + findNetworkClientIdByChainId: () => 'mainnet', + }, }, })); @@ -96,7 +99,7 @@ describe('shouldSkipValidation', () => { }); describe('validateToAddress', () => { - it('returns warning if address is contract address on mainnet', async () => { + it('returns warning if address is contract address', async () => { Engine.context.AssetsContractController.getERC721AssetSymbol = () => Promise.resolve('ABC'); expect( diff --git a/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.ts index 62aeb47271f..bb6338e519e 100644 --- a/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.ts +++ b/app/components/Views/confirmations/hooks/send/evm/useEvmToAddressValidation.ts @@ -12,7 +12,6 @@ import { isValidHexAddress, toChecksumAddress, } from '../../../../../../util/address'; -import { isMainnetByChainId } from '../../../../../../util/networks'; import { doENSLookup } from '../../../../../../util/ENSUtils'; import { collectConfusables, @@ -69,24 +68,25 @@ const validateHexAddress = async ( }> => { const checksummedAddress = toChecksumAddress(toAddress); if (chainId) { - const isMainnet = isMainnetByChainId(chainId); - const { AssetsContractController } = Engine.context; - // todo: This check should be done for all chains - if (isMainnet) { - try { - const symbol = await AssetsContractController.getERC721AssetSymbol( - checksummedAddress, - ); - if (symbol) { - // todo: i18n to be implemented depending on the designs - return { - warning: - 'This address is a token contract address. If you send tokens to this address, you will lose them.', - }; - } - } catch (e) { - // Not a token address + const { AssetsContractController, NetworkController } = Engine.context; + + try { + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + const symbol = await AssetsContractController.getERC721AssetSymbol( + checksummedAddress, + networkClientId, + ); + if (symbol) { + // todo: i18n to be implemented depending on the designs + return { + warning: + 'This address is a token contract address. If you send tokens to this address, you will lose them.', + }; } + } catch (e) { + // Not a token address } } return {}; diff --git a/app/components/Views/confirmations/hooks/send/useCurrencyConversions.test.ts b/app/components/Views/confirmations/hooks/send/useCurrencyConversions.test.ts index fb490c6f115..f67ce041318 100644 --- a/app/components/Views/confirmations/hooks/send/useCurrencyConversions.test.ts +++ b/app/components/Views/confirmations/hooks/send/useCurrencyConversions.test.ts @@ -26,9 +26,8 @@ describe('getFiatValueFn', () => { it('return fiat value for passed native value', () => { expect( getFiatValueFn({ - asset: { address: TOKEN_ADDRESS_MOCK_1 } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, amount: '10', decimals: 2, }), @@ -38,37 +37,56 @@ describe('getFiatValueFn', () => { it('return 0 if input is empty string', () => { expect( getFiatValueFn({ - asset: { address: TOKEN_ADDRESS_MOCK_1 } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, amount: '', decimals: 2, }), ).toStrictEqual(0); }); + + it('use conversionRate 1 if conversionRate is not passed', () => { + expect( + getFiatValueFn({ + conversionRate: undefined as unknown as number, + exchangeRate: 3890.556, + amount: '10', + decimals: 2, + }), + ).toStrictEqual(38905.56); + }); }); describe('getFiatDisplayValueFn', () => { it('return fiat value with currency prefix for passed native value', () => { expect( getFiatDisplayValueFn({ - asset: { address: TOKEN_ADDRESS_MOCK_1 } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, currentCurrency: 'usd', amount: '10', }), ).toStrictEqual('$ 38905.56'); }); + + it('return 0 if amount is not passed', () => { + expect( + getFiatDisplayValueFn({ + conversionRate: 1, + exchangeRate: 3890.556, + currentCurrency: 'usd', + amount: '', + }), + ).toStrictEqual('$ 0'); + }); }); describe('getNativeValueFn', () => { it('return native value for passed fiat value', () => { expect( getNativeValueFn({ - asset: { address: TOKEN_ADDRESS_MOCK_1 } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, amount: '38905.56', decimals: 2, }), @@ -78,14 +96,24 @@ describe('getNativeValueFn', () => { it('return 0 if input is empty string', () => { expect( getNativeValueFn({ - asset: { address: TOKEN_ADDRESS_MOCK_1 } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, amount: '', decimals: 2, }), ).toStrictEqual('0'); }); + + it('return 0 if input is invalid decimal', () => { + expect( + getNativeValueFn({ + conversionRate: 1, + exchangeRate: 3890.556, + amount: 'abc', + decimals: 2, + }), + ).toStrictEqual('0'); + }); }); describe('getNativeDisplayValueFn', () => { @@ -94,15 +122,26 @@ describe('getNativeDisplayValueFn', () => { getNativeDisplayValueFn({ asset: { address: TOKEN_ADDRESS_MOCK_1, symbol: 'ETH' } as AssetType, conversionRate: 1, - contractExchangeRates: { [TOKEN_ADDRESS_MOCK_1]: { price: 3890.556 } }, + exchangeRate: 3890.556, amount: '38905.56', }), ).toStrictEqual('ETH 10'); }); + + it('return 0 if amount is not passed', () => { + expect( + getNativeDisplayValueFn({ + asset: { address: TOKEN_ADDRESS_MOCK_1, symbol: 'ETH' } as AssetType, + conversionRate: 1, + exchangeRate: 3890.556, + amount: '', + }), + ).toStrictEqual('ETH 0'); + }); }); describe('useCurrencyConversions', () => { - it('return function getMaxAmount', () => { + it('return conversion functions', () => { const { result } = renderHookWithProvider( () => useCurrencyConversions(), mockState, diff --git a/app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts b/app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts index 73bc3f1e491..5e26b2f2e1a 100644 --- a/app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts +++ b/app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts @@ -1,5 +1,6 @@ -import { Hex } from '@metamask/utils'; -import { useCallback } from 'react'; +import { CaipAssetType, Hex } from '@metamask/utils'; +import { isAddress as isEvmAddress } from 'ethers/lib/utils'; +import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../../reducers'; @@ -13,70 +14,46 @@ import { selectConversionRateByChainId, selectCurrentCurrency, } from '../../../../../selectors/currencyRateController'; +import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; import { AssetType } from '../../types/token'; import { useSendContext } from '../../context/send-context'; +interface ConversionArgs { + amount?: string; + asset?: AssetType; + conversionRate: number; + currentCurrency?: string; + decimals?: number; + exchangeRate: number; +} + export const getFiatValueFn = ({ - asset, - conversionRate, - contractExchangeRates, amount, + conversionRate, decimals, -}: { - asset?: AssetType; - conversionRate?: number | null; - contractExchangeRates: Record; - amount?: string; - decimals?: number; -}) => { - const exchangeRate = asset?.address - ? contractExchangeRates?.[asset?.address as Hex]?.price ?? 1 - : 1; - return balanceToFiatNumber( - amount ?? 0, - conversionRate ?? 1, - exchangeRate, - decimals, - ); -}; + exchangeRate, +}: ConversionArgs) => + balanceToFiatNumber(amount ?? 0, conversionRate ?? 1, exchangeRate, decimals); export const getFiatDisplayValueFn = ({ - asset, + amount, conversionRate, - contractExchangeRates, currentCurrency, - amount, -}: { - asset?: AssetType; - conversionRate?: number | null; - contractExchangeRates: Record; - currentCurrency: string; - amount?: string; -}) => + exchangeRate, +}: ConversionArgs) => `${getCurrencySymbol(currentCurrency)} ${getFiatValueFn({ - asset, conversionRate, - contractExchangeRates, + exchangeRate, amount: amount ?? '0', decimals: 2, })}`; export const getNativeValueFn = ({ - asset, - conversionRate, - contractExchangeRates, amount, + conversionRate, decimals, -}: { - asset?: AssetType; - conversionRate?: number | null; - contractExchangeRates: Record; - amount?: string; - decimals?: number; -}) => { - const exchangeRate = asset?.address - ? contractExchangeRates?.[asset?.address as Hex]?.price ?? 1 - : 1; + exchangeRate, +}: ConversionArgs) => { let amt = amount ? parseFloat(amount) : 0; amt = Number.isNaN(amt) ? 0 : amt; const nativeValue = amt / ((conversionRate ?? 1) * exchangeRate); @@ -84,20 +61,14 @@ export const getNativeValueFn = ({ }; export const getNativeDisplayValueFn = ({ + amount, asset, conversionRate, - contractExchangeRates, - amount, -}: { - asset?: AssetType; - conversionRate?: number | null; - contractExchangeRates: Record; - amount?: string; -}) => + exchangeRate, +}: ConversionArgs) => `${asset?.symbol} ${getNativeValueFn({ - asset, conversionRate, - contractExchangeRates, + exchangeRate, amount: amount ?? '0', decimals: 5, })}`; @@ -105,23 +76,43 @@ export const getNativeDisplayValueFn = ({ export const useCurrencyConversions = () => { const { asset, chainId } = useSendContext(); const currentCurrency = useSelector(selectCurrentCurrency); - const conversionRate = useSelector((state: RootState) => + const conversionRateEvm = useSelector((state: RootState) => selectConversionRateByChainId(state, chainId), ); + const multichainAssetsRates = useSelector(selectMultichainAssetsRates); const contractExchangeRates = useSelector((state: RootState) => selectContractExchangeRatesByChainId(state, chainId as Hex), ); + const exchangeRate = useMemo( + () => + asset?.address + ? contractExchangeRates?.[asset?.address as Hex]?.price ?? 1 + : 1, + [asset?.address, contractExchangeRates], + ); + + const conversionRate = useMemo(() => { + if (!asset?.address) { + return 0; + } + if (isEvmAddress(asset?.address)) { + return conversionRateEvm ?? 0; + } + return parseFloat( + multichainAssetsRates[asset?.address as CaipAssetType]?.rate ?? 0, + ); + }, [asset?.address, conversionRateEvm, multichainAssetsRates]); + const getFiatDisplayValue = useCallback( (amount: string) => getFiatDisplayValueFn({ - asset, conversionRate, - contractExchangeRates, + exchangeRate, currentCurrency, amount, }), - [asset, conversionRate, contractExchangeRates, currentCurrency], + [conversionRate, exchangeRate, currentCurrency], ); const getNativeDisplayValue = useCallback( @@ -129,32 +120,30 @@ export const useCurrencyConversions = () => { getNativeDisplayValueFn({ asset, conversionRate, - contractExchangeRates, + exchangeRate, amount, }), - [asset, conversionRate, contractExchangeRates], + [asset, conversionRate, exchangeRate], ); const getFiatValue = useCallback( (amount: string) => getFiatValueFn({ - asset, conversionRate, - contractExchangeRates, + exchangeRate, amount, }), - [asset, conversionRate, contractExchangeRates], + [conversionRate, exchangeRate], ); const getNativeValue = useCallback( (amount: string) => getNativeValueFn({ - asset, conversionRate, - contractExchangeRates, + exchangeRate, amount, }), - [asset, conversionRate, contractExchangeRates], + [conversionRate, exchangeRate], ); return { diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest.ts index 18a94cb9e27..6dab7fe72b0 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest.ts @@ -1,10 +1,15 @@ import { ApprovalType } from '@metamask/controller-utils'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; import { useSelector } from 'react-redux'; import { selectTransactionMetadataById } from '../../../../../selectors/transactionController'; import { RootState } from '../../../../UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.test'; import useApprovalRequest from '../useApprovalRequest'; +import { EMPTY_ADDRESS } from '../../../../../constants/transaction'; export function useTransactionMetadataRequest() { const { approvalRequest } = useApprovalRequest(); @@ -23,12 +28,18 @@ export function useTransactionMetadataRequest() { return transactionMetadata as TransactionMeta; } -export function useTransactionMetadataOrThrow() { - const transactionMetadata = useTransactionMetadataRequest(); - - if (!transactionMetadata) { - throw new Error('Transaction approval request not found'); - } - - return transactionMetadata; +export function useTransactionMetadataOrThrow(): TransactionMeta { + return ( + useTransactionMetadataRequest() ?? { + id: '', + chainId: '0x123456', + networkClientId: '', + status: TransactionStatus.rejected, + time: 0, + txParams: { + from: EMPTY_ADDRESS, + }, + type: TransactionType.simpleSend, + } + ); } diff --git a/app/components/Views/confirmations/hooks/useTokenAmount.test.ts b/app/components/Views/confirmations/hooks/useTokenAmount.test.ts index 9451c815f33..1280854e468 100644 --- a/app/components/Views/confirmations/hooks/useTokenAmount.test.ts +++ b/app/components/Views/confirmations/hooks/useTokenAmount.test.ts @@ -276,6 +276,16 @@ describe('ERC20 token transactions', () => { data: '0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa9604500000000000000000000000000000000000000000000000002c68af0bb140000', }); }); + + it('returns native amount', async () => { + const { result } = renderHookWithProvider(() => useTokenAmount(), { + state: createERC20State(), + }); + + await waitFor(() => { + expect(result.current.amountNative).toBe('0.15'); + }); + }); }); describe('Edge cases', () => { diff --git a/app/components/Views/confirmations/hooks/useTokenAmount.ts b/app/components/Views/confirmations/hooks/useTokenAmount.ts index 4c59f720665..9f5681643d1 100644 --- a/app/components/Views/confirmations/hooks/useTokenAmount.ts +++ b/app/components/Views/confirmations/hooks/useTokenAmount.ts @@ -42,6 +42,7 @@ interface TokenAmountProps { interface TokenAmount { amount: string | undefined; + amountNative: string | undefined; amountPrecise: string | undefined; amountUnformatted: string | undefined; fiat: string | undefined; @@ -140,6 +141,7 @@ export const useTokenAmount = ({ if (pending) { return { amount: undefined, + amountNative: undefined, amountPrecise: undefined, amountUnformatted: undefined, fiat: undefined, @@ -159,6 +161,7 @@ export const useTokenAmount = ({ ); let fiat; + let native; let usdValue = null; let isNative = false; @@ -182,6 +185,7 @@ export const useTokenAmount = ({ const contractExchangeRate = contractExchangeRates?.[tokenAddress]?.price ?? 0; fiat = amount.times(nativeConversionRate).times(contractExchangeRate); + native = amount.times(contractExchangeRate); const usdAmount = amount .times(contractExchangeRate) @@ -198,6 +202,7 @@ export const useTokenAmount = ({ return { amount: formatAmount(I18n.locale, amount), + amountNative: native ? formatAmount(I18n.locale, native) : undefined, amountPrecise: formatAmountMaxPrecision(I18n.locale, amount), amountUnformatted: amount.toString(), fiat: fiat !== undefined ? fiatFormatter(fiat) : undefined, diff --git a/app/components/Views/confirmations/utils/send.test.ts b/app/components/Views/confirmations/utils/send.test.ts index 5966e58c82c..779a76dd341 100644 --- a/app/components/Views/confirmations/utils/send.test.ts +++ b/app/components/Views/confirmations/utils/send.test.ts @@ -101,7 +101,7 @@ describe('submitEvmTransaction', () => { submitEvmTransaction({ asset: { isNative: true } as AssetType, chainId: '0x1', - from: '0xeDd1935e28b253C7905Cf5a944f0B5830FFA916a', + from: '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', to: '0xeDd1935e28b253C7905Cf5a944f0B5830FFA967b', value: '10', }); diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index cf8eb722153..5ee1105e144 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -1112,6 +1112,8 @@ describe('Authentication', () => { checkIsPasswordOutdated: jest.fn(), } as unknown as SeedlessOnboardingController; Engine.context.KeyringController = { + setLocked: jest.fn(), + isUnlocked: jest.fn().mockResolvedValue(true), addNewKeyring: jest.fn(), createNewVaultAndRestore: jest.fn(), withKeyring: jest @@ -1144,8 +1146,9 @@ describe('Authentication', () => { type: SecretType.Mnemonic, }, ]); - const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + const newWalletVaultAndRestoreSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); await Authentication.userEntryAuth(mockPassword, mockAuthData); @@ -1157,13 +1160,12 @@ describe('Authentication', () => { mockSeedPhrase1, expect.any(Object), ); - expect(newWalletAndRestoreSpy).toHaveBeenCalledWith( + expect(newWalletVaultAndRestoreSpy).toHaveBeenCalledWith( mockPassword, - mockAuthData, uint8ArrayToMnemonic(mockSeedPhrase1, []), false, ); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn, passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn, passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1197,8 +1199,9 @@ describe('Authentication', () => { getState: jest.fn(() => mockStateLocal), } as unknown as ReduxStore); - const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + const newWalletVaultAndRestoreSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); ( Engine.context.KeyringController.addNewKeyring as jest.Mock @@ -1215,9 +1218,8 @@ describe('Authentication', () => { mockSeedPhrase1, expect.any(Object), ); - expect(newWalletAndRestoreSpy).toHaveBeenCalledWith( + expect(newWalletVaultAndRestoreSpy).toHaveBeenCalledWith( mockPassword, - mockAuthData, uint8ArrayToMnemonic(mockSeedPhrase1, []), false, ); @@ -1234,7 +1236,7 @@ describe('Authentication', () => { keyringId: 'new-keyring-id', type: 'mnemonic', }); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn, passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn, passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1252,8 +1254,9 @@ describe('Authentication', () => { type: SecretType.PrivateKey, }, ]); - const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + const newWalletVaultAndRestoreSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); const importAccountFromPrivateKeySpy = jest .spyOn(Authentication, 'importAccountFromPrivateKey') @@ -1264,9 +1267,8 @@ describe('Authentication', () => { expect( Engine.context.SeedlessOnboardingController.fetchAllSecretData, ).toHaveBeenCalledWith(mockPassword); - expect(newWalletAndRestoreSpy).toHaveBeenCalledWith( + expect(newWalletVaultAndRestoreSpy).toHaveBeenCalledWith( mockPassword, - mockAuthData, uint8ArrayToMnemonic(mockSeedPhrase1, []), false, ); @@ -1277,7 +1279,7 @@ describe('Authentication', () => { shouldSelectAccount: false, }, ); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn and passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn and passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1296,14 +1298,15 @@ describe('Authentication', () => { }, ]); const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); await Authentication.userEntryAuth(mockPassword, mockAuthData); expect(newWalletAndRestoreSpy).toHaveBeenCalled(); expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), 'unknown'); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn and passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn and passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1321,8 +1324,10 @@ describe('Authentication', () => { type: SecretType.PrivateKey, }, ]); - const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + + const newWalletVaultAndRestoreSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); const importError = new Error('Import failed'); const importAccountFromPrivateKeySpy = jest @@ -1331,10 +1336,10 @@ describe('Authentication', () => { await Authentication.userEntryAuth(mockPassword, mockAuthData); - expect(newWalletAndRestoreSpy).toHaveBeenCalled(); + expect(newWalletVaultAndRestoreSpy).toHaveBeenCalled(); expect(importAccountFromPrivateKeySpy).toHaveBeenCalled(); expect(Logger.error).toHaveBeenCalledWith(importError); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn and passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn and passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1373,8 +1378,9 @@ describe('Authentication', () => { type: SecretType.Mnemonic, }, ]); - const newWalletAndRestoreSpy = jest - .spyOn(Authentication, 'newWalletAndRestore') + const newWalletVaultAndRestoreSpy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(Authentication as any, 'newWalletVaultAndRestore') .mockResolvedValueOnce(undefined); const error = new Error('Keyring add failed'); ( @@ -1403,12 +1409,12 @@ describe('Authentication', () => { expect( Engine.context.KeyringController.addNewKeyring, ).toHaveBeenCalledTimes(1); - expect(newWalletAndRestoreSpy).toHaveBeenCalled(); + expect(newWalletVaultAndRestoreSpy).toHaveBeenCalled(); expect( Engine.context.SeedlessOnboardingController.updateBackupMetadataState, ).not.toHaveBeenCalled(); expect(Logger.error).toHaveBeenCalledWith(error); - expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(2); // logIn, passwordSet + expect(ReduxService.store.dispatch).toHaveBeenCalledTimes(3); // logIn, passwordSet, setExistingUser expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); @@ -1819,9 +1825,7 @@ describe('Authentication', () => { } as unknown as ReduxStore); await expect( - Authentication.syncPasswordAndUnlockWallet(mockGlobalPassword, { - currentAuthType: AUTHENTICATION_TYPE.PASSWORD, - }), + Authentication.syncPasswordAndUnlockWallet(mockGlobalPassword), ).rejects.toThrow('change password failed'); expect(Authentication.lockApp).toHaveBeenCalledWith({ locked: true }); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index 39e63d71400..594c352fff8 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -512,10 +512,10 @@ class AuthenticationService { if (authData.oauth2Login) { // if seedless flow - rehydrate - await this.rehydrateSeedPhrase(password, authData); + await this.rehydrateSeedPhrase(password); } else if (await this.checkIsSeedlessPasswordOutdated(false)) { // if seedless flow completed && seedless password is outdated, sync the password and unlock the wallet - await this.syncPasswordAndUnlockWallet(password, authData); + await this.syncPasswordAndUnlockWallet(password); } else { // else srp flow await this.loginVaultCreation(password); @@ -902,10 +902,7 @@ class AuthenticationService { } }; - rehydrateSeedPhrase = async ( - password: string, - authData: AuthData, - ): Promise => { + rehydrateSeedPhrase = async (password: string): Promise => { try { const { SeedlessOnboardingController } = Engine.context; let allSRPs: Awaited< @@ -949,7 +946,8 @@ class AuthenticationService { } const seedPhrase = uint8ArrayToMnemonic(firstSeedPhrase.data, wordlist); - await this.newWalletAndRestore(password, authData, seedPhrase, false); + + await this.newWalletVaultAndRestore(password, seedPhrase, false); // add in more srps const keyringMetadataList: KeyringMetadata[] = []; if (restOfSeedPhrases.length > 0) { @@ -980,10 +978,14 @@ class AuthenticationService { this.addMultichainAccounts(keyringMetadataList); this.dispatchOauthReset(); + + ReduxService.store.dispatch(setExistingUser(true)); + await StorageWrapper.removeItem(SEED_PHRASE_HINTS); } else { throw new Error('No account data found'); } } catch (error) { + this.lockApp({ reset: false }); Logger.error(error as Error); throw error; } @@ -997,7 +999,6 @@ class AuthenticationService { */ syncPasswordAndUnlockWallet = async ( globalPassword: string, - authData: AuthData, ): Promise => { const { SeedlessOnboardingController, KeyringController } = Engine.context; @@ -1036,7 +1037,7 @@ class AuthenticationService { // rehydrate with social accounts if max keychain length exceeded await SeedlessOnboardingController.refreshAuthTokens(); - await this.rehydrateSeedPhrase(globalPassword, authData); + await this.rehydrateSeedPhrase(globalPassword); // skip the rest of the flow ( change password and sync keyring encryption key) return; } else if ( diff --git a/app/core/redux/slices/confirmationMetrics/index.test.ts b/app/core/redux/slices/confirmationMetrics/index.test.ts index b24186fc00b..99138c5dcaa 100644 --- a/app/core/redux/slices/confirmationMetrics/index.test.ts +++ b/app/core/redux/slices/confirmationMetrics/index.test.ts @@ -9,6 +9,8 @@ import reducer, { selectTransactionPayToken, setTransactionBridgeQuotes, selectTransactionBridgeQuotesById, + selectIsTransactionBridgeQuotesLoadingById, + setTransactionBridgeQuotesLoading, } from './index'; import { RootState } from '../../../../reducers'; import { TransactionBridgeQuote } from '../../../../components/Views/confirmations/utils/bridge'; @@ -186,4 +188,43 @@ describe('confirmationMetrics slice', () => { ]); }); }); + + describe('selectTransactionBridgeQuotesLoadingById', () => { + it('returns true if set as loading in state', () => { + const state = { + confirmationMetrics: { + isTransactionBridgeQuotesLoadingById: { [ID_MOCK]: true }, + }, + } as unknown as RootState; + + expect(selectIsTransactionBridgeQuotesLoadingById(state, ID_MOCK)).toBe( + true, + ); + }); + + it('returns false if not in state', () => { + const state = { + confirmationMetrics: { + isTransactionBridgeQuotesLoadingById: {}, + }, + } as unknown as RootState; + + expect(selectIsTransactionBridgeQuotesLoadingById(state, ID_MOCK)).toBe( + false, + ); + }); + }); + + describe('setTransactionBridgeQuotesLoading', () => { + it('updates loading state for ID', () => { + const action = setTransactionBridgeQuotesLoading({ + transactionId: ID_MOCK, + isLoading: true, + }); + + const state = reducer(initialState, action); + + expect(state.isTransactionBridgeQuotesLoadingById[ID_MOCK]).toBe(true); + }); + }); }); diff --git a/app/core/redux/slices/confirmationMetrics/index.ts b/app/core/redux/slices/confirmationMetrics/index.ts index 438004d7abb..f5f7e5de719 100644 --- a/app/core/redux/slices/confirmationMetrics/index.ts +++ b/app/core/redux/slices/confirmationMetrics/index.ts @@ -22,12 +22,14 @@ export interface ConfirmationMetricsState { string, TransactionBridgeQuote[] | undefined >; + isTransactionBridgeQuotesLoadingById: Record; } export const initialState: ConfirmationMetricsState = { metricsById: {}, transactionPayTokenById: {}, transactionBridgeQuotesById: {}, + isTransactionBridgeQuotesLoadingById: {}, }; const name = 'confirmationMetrics'; @@ -76,6 +78,17 @@ const slice = createSlice({ const { transactionId, quotes } = action.payload; state.transactionBridgeQuotesById[transactionId] = quotes; }, + + setTransactionBridgeQuotesLoading: ( + state, + action: PayloadAction<{ + transactionId: string; + isLoading: boolean; + }>, + ) => { + const { transactionId, isLoading } = action.payload; + state.isTransactionBridgeQuotesLoadingById[transactionId] = isLoading; + }, }, }); @@ -88,6 +101,7 @@ export const { updateConfirmationMetric, setTransactionPayToken, setTransactionBridgeQuotes, + setTransactionBridgeQuotesLoading, } = actions; // Selectors @@ -108,3 +122,10 @@ export const selectTransactionBridgeQuotesById = createSelector( (transactionBridgeQuotesById, transactionId) => transactionBridgeQuotesById[transactionId], ); + +export const selectIsTransactionBridgeQuotesLoadingById = createSelector( + (state: RootState) => state[name].isTransactionBridgeQuotesLoadingById, + (_: RootState, transactionId: string) => transactionId, + (isTransactionBridgeQuotesLoadingById, transactionId) => + isTransactionBridgeQuotesLoadingById[transactionId] ?? false, +); diff --git a/app/store/migrations/091.test.ts b/app/store/migrations/091.test.ts new file mode 100644 index 00000000000..8dfa472c6ad --- /dev/null +++ b/app/store/migrations/091.test.ts @@ -0,0 +1,133 @@ +import { captureException } from '@sentry/react-native'; +import { hasProperty } from '@metamask/utils'; +import { ensureValidState } from './util'; +import migrate from './091'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + hasProperty: jest.fn(), +})); + +const mockedCaptureException = jest.mocked(captureException); +const mockedEnsureValidState = jest.mocked(ensureValidState); +const mockedHasProperty = jest.mocked(hasProperty); + +describe('Migration 091', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns state unchanged if ensureValidState fails', () => { + const state = { some: 'state' }; + + mockedEnsureValidState.mockReturnValue(false); + + const migratedState = migrate(state); + + expect(migratedState).toBe(state); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('removes alert state when it exists in the state', () => { + const state = { + alert: { + isVisible: true, + autodismiss: 1500, + content: 'clipboard-alert', + data: { msg: 'Public address copied to clipboard' }, + }, + user: { existingUser: true }, + settings: { theme: 'dark' }, + engine: { backgroundState: {} }, + }; + + mockedEnsureValidState.mockReturnValue(true); + mockedHasProperty.mockReturnValue(true); + + const migratedState = migrate(state); + + expect(migratedState).toEqual({ + user: { existingUser: true }, + settings: { theme: 'dark' }, + engine: { backgroundState: {} }, + }); + + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('handles empty state object', () => { + const state = {}; + + mockedEnsureValidState.mockReturnValue(true); + mockedHasProperty.mockReturnValue(false); + + const migratedState = migrate(state); + + expect(migratedState).toBe(state); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('handles state with null alert property', () => { + const state = { + alert: null, + user: { existingUser: true }, + }; + + mockedEnsureValidState.mockReturnValue(true); + mockedHasProperty.mockReturnValue(true); + + const migratedState = migrate(state); + + expect(migratedState).toEqual({ + user: { existingUser: true }, + }); + + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('returns state unchanged when alert state does not exist', () => { + const state = { + user: { existingUser: true }, + settings: { theme: 'dark' }, + engine: { backgroundState: {} }, + }; + + mockedEnsureValidState.mockReturnValue(true); + mockedHasProperty.mockReturnValue(false); + + const migratedState = migrate(state); + + expect(migratedState).toBe(state); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('captures exception and returns original state when an error occurs', () => { + const state = { + alert: { + isVisible: true, + content: 'clipboard-alert', + }, + }; + + mockedEnsureValidState.mockReturnValue(true); + mockedHasProperty.mockImplementation(() => { + throw new Error('Unexpected error during property check'); + }); + + const migratedState = migrate(state); + + expect(migratedState).toBe(state); + expect(mockedCaptureException).toHaveBeenCalledWith( + new Error( + 'Migration 091: Failed to remove alert state from persisted data: Error: Unexpected error during property check', + ), + ); + }); +}); diff --git a/app/store/migrations/091.ts b/app/store/migrations/091.ts new file mode 100644 index 00000000000..0c91f8ac10d --- /dev/null +++ b/app/store/migrations/091.ts @@ -0,0 +1,36 @@ +import { hasProperty } from '@metamask/utils'; +import { captureException } from '@sentry/react-native'; +import { ensureValidState } from './util'; + +/** + * Migration 091: Remove alert state from persisted data + * + * This migration fixes the issue where clipboard alerts persist across app restarts. + * Alert state should be ephemeral and not persisted. This removes any existing + * alert state that was incorrectly persisted in previous versions. + */ +export default function migrate(state: unknown): unknown { + if (!ensureValidState(state, 91)) { + return state; + } + + try { + if (hasProperty(state, 'alert')) { + const { alert: alertState, ...stateWithoutAlert } = state; + return stateWithoutAlert; + } + + return state; + } catch (error) { + captureException( + new Error( + `Migration 091: Failed to remove alert state from persisted data: ${String( + error, + )}`, + ), + ); + + // Return the original state if migration fails to avoid breaking the app + return state; + } +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index d6761eed988..9c5002e6b19 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -91,6 +91,7 @@ import migration87 from './087'; import migration88 from './088'; import migration89 from './089'; import migration90 from './090'; +import migration91 from './091'; // Add migrations above this line import { validatePostMigrationState } from '../validateMigration/validateMigration'; @@ -198,6 +199,7 @@ export const migrationList: MigrationsList = { 88: migration88, 89: migration89, 90: migration90, + 91: migration91, }; // Enable both synchronous and asynchronous migrations diff --git a/app/store/persistConfig.test.ts b/app/store/persistConfig.test.ts index 3ba9f5e1ef6..2e4ba394812 100644 --- a/app/store/persistConfig.test.ts +++ b/app/store/persistConfig.test.ts @@ -116,6 +116,7 @@ describe('persistConfig', () => { 'rpcEvents', 'accounts', 'confirmationMetrics', + 'alert', ]); }); diff --git a/app/store/persistConfig.ts b/app/store/persistConfig.ts index 41e2996d584..b8f3bc19474 100644 --- a/app/store/persistConfig.ts +++ b/app/store/persistConfig.ts @@ -135,7 +135,7 @@ const persistOnboardingTransform = createTransform( const persistConfig = { key: 'root', version, - blacklist: ['rpcEvents', 'accounts', 'confirmationMetrics'], + blacklist: ['rpcEvents', 'accounts', 'confirmationMetrics', 'alert'], storage: MigratedStorage, transforms: [ persistTransform, diff --git a/e2e/pages/Browser/TestSnaps.ts b/e2e/pages/Browser/TestSnaps.ts index d8cacb03544..a760b7fef9a 100644 --- a/e2e/pages/Browser/TestSnaps.ts +++ b/e2e/pages/Browser/TestSnaps.ts @@ -129,11 +129,31 @@ class TestSnaps { const webElement = Matchers.getElementByWebID( BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID, TestSnapViewSelectorWebIDS[buttonLocator], - ) as any; + ); await Gestures.scrollToWebViewPort(webElement); await Gestures.tapWebElement(webElement); } + async tapOkButton() { + const button = Matchers.getElementByText('OK'); + await Gestures.waitAndTap(button); + } + + async tapApproveButton() { + const button = Matchers.getElementByText('Approve'); + await Gestures.waitAndTap(button); + } + + async tapConfirmButton() { + const button = Matchers.getElementByText('Confirm'); + await Gestures.waitAndTap(button); + } + + async tapCancelButton() { + const button = Matchers.getElementByText('Cancel'); + await Gestures.waitAndTap(button); + } + async getOptionValueByText( webElement: IndexableWebElement, text: string, diff --git a/e2e/selectors/Browser/TestSnaps.selectors.ts b/e2e/selectors/Browser/TestSnaps.selectors.ts index 51df53ef99d..019af87f516 100644 --- a/e2e/selectors/Browser/TestSnaps.selectors.ts +++ b/e2e/selectors/Browser/TestSnaps.selectors.ts @@ -7,6 +7,7 @@ export const TestSnapViewSelectorWebIDS = { connectBip32Button: 'connectbip32', connectBip44Button: 'connectbip44', connectClientStatusSnapButton: 'connectclient-status', + connectDialogSnapButton: 'connectdialogs', connectGetEntropyButton: 'connectGetEntropySnap', connectGetPreferencesButton: 'connectpreferences', connectJsonRpcButton: 'connectjson-rpc', @@ -25,7 +26,10 @@ export const TestSnapViewSelectorWebIDS = { signMessageBip32Secp256k1Button: 'sendBip32-secp256k1', signMessageBip32ed25519Button: 'sendBip32-ed25519', signMessageBip32ed25519Bip32Button: 'sendBip32-ed25519Bip32', + sendAlertButton: 'sendAlertButton', sendClientStatusButton: 'sendClientStatusTest', + sendConfirmationButton: 'sendConfirmationButton', + sendCustomButton: 'sendCustomButton', sendGetStateButton: 'sendGetState', sendGetUnencryptedStateButton: 'sendGetUnencryptedState', sendManageStateButton: 'sendManageState', @@ -81,6 +85,7 @@ export const TestSnapResultSelectorWebIDS = { clearManageStateResultSpan: 'clearManageStateResult', clearUnencryptedManageStateResultSpan: 'clearUnencryptedManageStateResult', clientStatusResultSpan: 'clientStatusResult', + dialogResultSpan: 'dialogResult', encryptedStateResultSpan: 'encryptedStateResult', entropySignResultSpan: 'entropySignResult', preferencesResultSpan: 'preferencesResult', diff --git a/e2e/specs/snaps/test-snap-dialog.spec.ts b/e2e/specs/snaps/test-snap-dialog.spec.ts new file mode 100644 index 00000000000..0a873e20349 --- /dev/null +++ b/e2e/specs/snaps/test-snap-dialog.spec.ts @@ -0,0 +1,103 @@ +import { FlaskBuildTests } from '../../tags'; +import { loginToApp } from '../../viewHelper'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import TestSnaps from '../../pages/Browser/TestSnaps'; +import Assertions from '../../framework/Assertions'; +import Gestures from '../../utils/Gestures'; +import { Matchers } from '../../framework'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; + +jest.setTimeout(150_000); + +describe(FlaskBuildTests('Dialog Snap Tests'), () => { + it('connects to the Dialog Snap', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + }, + async () => { + await loginToApp(); + await TabBarComponent.tapBrowser(); + await TestSnaps.navigateToTestSnap(); + + await TestSnaps.installSnap('connectDialogSnapButton'); + }, + ); + }); + + describe('alert', () => { + it('shows an alert dialog', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + }, + async () => { + await TestSnaps.tapButton('sendAlertButton'); + await Assertions.expectTextDisplayed( + 'This is an alert dialog. It has a single button: "OK".', + ); + + await TestSnaps.tapOkButton(); + await TestSnaps.checkResultSpan('dialogResultSpan', 'null'); + }, + ); + }); + }); + + describe('confirmation', () => { + it('shows a confirmation dialog', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + }, + async () => { + await TestSnaps.tapButton('sendConfirmationButton'); + await Assertions.expectTextDisplayed('Confirmation Dialog'); + + await TestSnaps.tapApproveButton(); + await TestSnaps.checkResultSpan('dialogResultSpan', 'true'); + }, + ); + }); + + it('shows a confirmation dialog and cancels', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + }, + async () => { + await TestSnaps.tapButton('sendConfirmationButton'); + await Assertions.expectTextDisplayed('Confirmation Dialog'); + + await TestSnaps.tapCancelButton(); + await TestSnaps.checkResultSpan('dialogResultSpan', 'false'); + }, + ); + }); + }); + + describe('custom', () => { + it('shows a custom dialog', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + }, + async () => { + await TestSnaps.tapButton('sendCustomButton'); + await Assertions.expectTextDisplayed('Custom Dialog'); + + const input = Matchers.getElementByID('custom-input-snap-ui-input'); + await Gestures.typeTextAndHideKeyboard(input, 'Hello, World!'); + + await TestSnaps.tapConfirmButton(); + await TestSnaps.checkResultSpan( + 'dialogResultSpan', + '"Hello, World!"', + ); + }, + ); + }); + }); +}); diff --git a/jest.config.js b/jest.config.js index eb500e49f89..63d69e14952 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,7 @@ process.env.MM_FOX_CODE = 'EXAMPLE_FOX_CODE'; process.env.MM_SECURITY_ALERTS_API_ENABLED = 'true'; process.env.PORTFOLIO_VIEW = 'true'; process.env.SECURITY_ALERTS_API_URL = 'https://example.com'; +process.env.MM_CONFIRMATION_INTENTS = 'true'; process.env.LAUNCH_DARKLY_URL = 'https://client-config.dev-api.cx.metamask.io/v1'; diff --git a/locales/languages/en.json b/locales/languages/en.json index 27ebaf47c8b..3f1f365bb67 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -49,6 +49,12 @@ "batched_unused_approvals": { "title": "Unnecessary permission", "message": "You're giving someone else permission to withdraw your tokens, even though it's not necessary for this transaction." + }, + "perps_deposit_minimum": { + "message": "Min order value is $10" + }, + "insufficient_pay_token_balance": { + "message": "Insufficient funds. Select different token." } }, "blockaid_banner": { @@ -4811,7 +4817,8 @@ "now": "Now", "switching_to": "Switching To", "bridge_estimated_time": "Est. time", - "pay_with": "Pay with" + "pay_with": "Pay with", + "total": "Total" }, "title": { "signature": "Signature request", @@ -4931,7 +4938,8 @@ "review": "Review", "transferRequest": "Transfer request", "nested_transaction_heading": "Transaction {{index}}", - "transaction": "Transaction" + "transaction": "Transaction", + "available_balance": "Available: " }, "change_in_simulation_modal": { "title": "Results have changed",