diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx index f0b184305125..2ac7dad75593 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.test.tsx @@ -84,15 +84,18 @@ describe('GasFeeTokenModal', () => { transactionId = 'test-transaction-id', gasFeeTokens = [], selectedGasFeeToken = undefined, + excludeNativeTokenForFee, }: { transactionId?: string; gasFeeTokens?: GasFeeToken[]; selectedGasFeeToken?: string; + excludeNativeTokenForFee?: boolean; } = {}) => { mockUseTransactionMetadataRequest.mockReturnValue({ id: transactionId, gasFeeTokens, selectedGasFeeToken, + excludeNativeTokenForFee, } as ReturnType); const selectedToken = selectedGasFeeToken @@ -216,6 +219,19 @@ describe('GasFeeTokenModal', () => { await waitFor(() => expect(mockOnClose).toHaveBeenCalled()); }); + it('never renders native list item if `excludeNativeTokenForFee` is set to `true`', () => { + const { queryByTestId } = setupTest({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + selectedGasFeeToken: GAS_FEE_TOKEN_MOCK.tokenAddress, + excludeNativeTokenForFee: true, + }); + expect( + queryByTestId( + `gas-fee-token-list-item-${MOCK_NATIVE_USE_GAS_FEE_TOKEN.symbol}`, + ), + ).toBeNull(); + }); + it('shows native token as selected when no gas fee token is selected', () => { const { getByTestId } = setupTest({ gasFeeTokens: [GAS_FEE_TOKEN_MOCK], diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx index c1fe29ab4696..8401b39e9576 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-modal/gas-fee-token-modal.tsx @@ -21,10 +21,11 @@ export function GasFeeTokenModal({ onClose }: { onClose?: () => void }) { id: transactionId = '', gasFeeTokens, selectedGasFeeToken, + excludeNativeTokenForFee, } = transactionMeta || {}; const gasFeeTokenAddresses = [ - NATIVE_TOKEN_ADDRESS as Hex, + ...(excludeNativeTokenForFee ? [] : [NATIVE_TOKEN_ADDRESS as Hex]), ...(gasFeeTokens // Temporarily disable future ETH flow ?.filter((token) => token.tokenAddress !== NATIVE_TOKEN_ADDRESS) diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx index fd7090251173..1f1f12bc65b3 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx @@ -14,6 +14,7 @@ import { Alert } from '../../../types/alerts'; import { GasFeeToken } from '@metamask/transaction-controller'; import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { Hex } from '@metamask/utils'; jest.mock('../../../hooks/alerts/useInsufficientBalanceAlert'); jest.mock('../../../hooks/gas/useEstimationFailed'); @@ -47,6 +48,8 @@ describe('SelectedGasFeeToken', () => { gasFeeTokens = [], transactionMetadata, estimationFailed = false, + excludeNativeTokenForFee, + chainId = '0x1', }: { insufficientBalance?: Alert[]; selectedGasFeeToken?: ReturnType; @@ -58,6 +61,8 @@ describe('SelectedGasFeeToken', () => { > | null; expectModal?: boolean; estimationFailed?: boolean; + excludeNativeTokenForFee?: boolean; + chainId?: Hex; } = {}) => { mockUseInsufficientBalanceAlert.mockReturnValue(insufficientBalance); mockUseEstimationFailed.mockReturnValue(estimationFailed); @@ -81,14 +86,16 @@ describe('SelectedGasFeeToken', () => { mockUseTransactionMetadataRequest.mockReturnValue(transactionMetadata); } else if (gasFeeTokens.length > 0) { mockUseTransactionMetadataRequest.mockReturnValue({ - chainId: '0x1', + chainId, gasFeeTokens, + excludeNativeTokenForFee, } as Partial< ReturnType > as ReturnType); } else { mockUseTransactionMetadataRequest.mockReturnValue({ - chainId: '0x1', + chainId, + excludeNativeTokenForFee, } as Partial< ReturnType > as ReturnType); @@ -119,7 +126,9 @@ describe('SelectedGasFeeToken', () => { pressTokenButton: () => fireEvent.press(renderResult.getByTestId('selected-gas-fee-token')), expectModalToOpen: () => { - expect(renderResult.queryByTestId('gas-fee-token-modal')).toBeNull(); + expect( + renderResult.queryByTestId('gas-fee-token-modal'), + ).not.toBeOnTheScreen(); fireEvent.press(renderResult.getByTestId('selected-gas-fee-token')); expect( renderResult.getByTestId('gas-fee-token-modal'), @@ -127,7 +136,9 @@ describe('SelectedGasFeeToken', () => { }, expectModalNotToOpen: () => { fireEvent.press(renderResult.getByTestId('selected-gas-fee-token')); - expect(renderResult.queryByTestId('gas-fee-token-modal')).toBeNull(); + expect( + renderResult.queryByTestId('gas-fee-token-modal'), + ).not.toBeOnTheScreen(); }, }; }; @@ -145,6 +156,18 @@ describe('SelectedGasFeeToken', () => { expect(getByText('ETH')).toBeOnTheScreen(); }); + it('renders local native symbol if `gasFeeTokens` is empty and `excludeNativeTokenForFee` is set (Tempo)', () => { + const { getByTestId, getByText } = setupTest({ + chainId: '0x1079', + selectedGasFeeToken: undefined, + gaslessSupported: true, + gasFeeTokens: [] as unknown as GasFeeToken[], + excludeNativeTokenForFee: true, + }); + expect(getByTestId('selected-gas-fee-token')).toBeOnTheScreen(); + expect(getByText('pathUSD')).toBeOnTheScreen(); + }); + it('renders the arrow icon when gas fee tokens are available', () => { const { getByTestId, getByText } = setupTest({ selectedGasFeeToken: { @@ -165,7 +188,40 @@ describe('SelectedGasFeeToken', () => { it('does not render the arrow icon when no gas fee tokens are available', () => { const { queryByTestId } = setupTest(); - expect(queryByTestId('selected-gas-fee-token-arrow')).toBeNull(); + expect(queryByTestId('selected-gas-fee-token-arrow')).not.toBeOnTheScreen(); + }); + + it('does not render arrow icon if only one gas fee token and `excludeNativeTokenForFee` is set', () => { + const { queryByTestId } = setupTest({ + selectedGasFeeToken: { + tokenAddress: '0xTokenAddress', + symbol: 'DAI', + } as unknown as ReturnType, + gaslessSupported: true, + gasFeeTokens: [ + { tokenAddress: '0xTokenAddress', symbol: 'DAI' }, + ] as unknown as GasFeeToken[], + excludeNativeTokenForFee: true, + }); + expect(queryByTestId('selected-gas-fee-token-arrow')).not.toBeOnTheScreen(); + }); + + it('still renders the arrow icon if two non-native gas fee tokens and `excludeNativeTokenForFee` is set', () => { + const { getByTestId, getByText } = setupTest({ + selectedGasFeeToken: { + tokenAddress: '0xTokenAddress', + symbol: 'DAI', + } as unknown as ReturnType, + gaslessSupported: true, + gasFeeTokens: [ + { tokenAddress: '0xTokenAddress', symbol: 'DAI' }, + { tokenAddress: '0xOtherTokenAddress', symbol: 'USDS' }, + ] as unknown as GasFeeToken[], + excludeNativeTokenForFee: true, + }); + expect(getByTestId('selected-gas-fee-token')).toBeOnTheScreen(); + expect(getByText('DAI')).toBeOnTheScreen(); + expect(getByTestId('selected-gas-fee-token-arrow')).toBeOnTheScreen(); }); describe('Modal', () => { @@ -213,7 +269,7 @@ describe('SelectedGasFeeToken', () => { // Close modal fireEvent.press(getByTestId('close-button')); - expect(queryByTestId('gas-fee-token-modal')).toBeNull(); + expect(queryByTestId('gas-fee-token-modal')).not.toBeOnTheScreen(); }); describe('Future native token', () => { @@ -304,7 +360,9 @@ describe('SelectedGasFeeToken', () => { estimationFailed: true, }); - expect(queryByTestId('selected-gas-fee-token-arrow')).toBeNull(); + expect( + queryByTestId('selected-gas-fee-token-arrow'), + ).not.toBeOnTheScreen(); }); it('opens modal when estimation has not failed and gas fee tokens are supported', () => { diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx index 67477f0b44f1..56a23fa1db17 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { TouchableOpacity } from 'react-native'; import Icon, { IconName, @@ -13,16 +13,20 @@ import { useIsGaslessSupported } from '../../../hooks/gas/useIsGaslessSupported' import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { useIsInsufficientBalance } from '../../../hooks/useIsInsufficientBalance'; import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; -import useNetworkInfo from '../../../hooks/useNetworkInfo'; import { GasFeeTokenIcon, GasFeeTokenIconSize } from '../gas-fee-token-icon'; import { GasFeeTokenModal } from '../gas-fee-token-modal'; import styleSheet from './selected-gas-fee-token.styles'; +import { useNativeCurrencySymbol } from '../../../hooks/useNativeCurrencySymbol'; export function SelectedGasFeeToken() { const [isModalOpen, setIsModalOpen] = useState(false); const transactionMetadata = useTransactionMetadataRequest(); const transactionBatchesMetadata = useTransactionBatchesMetadata(); - const { chainId: chainIdSingle, gasFeeTokens } = transactionMetadata || {}; + const { + chainId: chainIdSingle, + gasFeeTokens, + excludeNativeTokenForFee, + } = transactionMetadata || {}; const { chainId: chainIdBatch } = transactionBatchesMetadata || {}; const chainId = chainIdSingle ?? chainIdBatch; const hasGasFeeTokens = Boolean(gasFeeTokens?.length); @@ -50,19 +54,34 @@ export function SelectedGasFeeToken() { hasGasFeeTokens && (!hasOnlyFutureNativeToken || supportsFutureNative); - const { networkNativeCurrency: nativeCurrency } = useNetworkInfo(chainId); + const nonNativeGasFeeTokensLength = useMemo( + () => + ( + gasFeeTokens?.filter( + (token) => + token.tokenAddress && token.tokenAddress !== NATIVE_TOKEN_ADDRESS, + ) ?? [] + ).length, + [gasFeeTokens], + ); + // Disable fee token choice selection if only 1 token available. + // Taking in account networks that don't have a native token. + const hasMoreThanOneGasFeeTokenToChooseFrom = excludeNativeTokenForFee + ? supportsGasFeeTokens && nonNativeGasFeeTokensLength > 1 + : supportsGasFeeTokens; + + const { nativeCurrencySymbol } = useNativeCurrencySymbol(chainId); const handlePress = useCallback(() => { - if (!supportsGasFeeTokens) { + if (!hasMoreThanOneGasFeeTokenToChooseFrom) { return; } setIsModalOpen(true); - }, [supportsGasFeeTokens]); + }, [hasMoreThanOneGasFeeTokenToChooseFrom]); - const nativeTicker = nativeCurrency; const gasFeeToken = useSelectedGasFeeToken(); - const symbol = gasFeeToken?.symbol ?? nativeTicker; + const gasTokenSymbol = gasFeeToken?.symbol ?? nativeCurrencySymbol; return ( <> @@ -73,14 +92,14 @@ export function SelectedGasFeeToken() { onPress={handlePress} style={styles.gasFeeTokenButton} testID="selected-gas-fee-token" - disabled={!supportsGasFeeTokens} + disabled={!hasMoreThanOneGasFeeTokenToChooseFrom} > - {symbol} - {supportsGasFeeTokens && ( + {gasTokenSymbol} + {hasMoreThanOneGasFeeTokenToChooseFrom && ( useGasFeeToken({ tokenAddress }), state, @@ -153,6 +159,23 @@ describe('useGasFeeToken', () => { expect(result.tokenAddress).toStrictEqual(NATIVE_TOKEN_ADDRESS); }); + it('returns native gas fee token if `tokenAddress` doesnt match any `gasFeeTokens`', () => { + const result = runHook({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + tokenAddress: '0x00000000000d6ffc74a8feb35af5827bf57f6786', // non-existing + }); + expect(result.tokenAddress).toStrictEqual(NATIVE_TOKEN_ADDRESS); + }); + + it('returns first of gasFeeTokens if `tokenAddress` doesnt match any `gasFeeTokens` but `excludeNativeTokenForFee` is set', () => { + const result = runHook({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + tokenAddress: '0x00000000000d6ffc74a8feb35af5827bf57f6786', // non-existing + excludeNativeTokenForFee: true, + }); + expect(result.tokenAddress).toStrictEqual(GAS_FEE_TOKEN_MOCK.tokenAddress); + }); + it('returns token transfer transaction when tokenAddress is not the native token address', () => { const result = runHook({ tokenAddress: GAS_FEE_TOKEN_MOCK.tokenAddress }); expect(result.transferTransaction).toEqual( diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeToken.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeToken.ts index da10ad725f63..044fdc374cd6 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeToken.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeToken.ts @@ -5,7 +5,6 @@ import { TransactionMeta, } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; -import { useSelector } from 'react-redux'; import { Interface } from '@ethersproject/abi'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens'; @@ -13,11 +12,10 @@ import I18n from '../../../../../../locales/i18n'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { formatAmount } from '../../../../UI/SimulationDetails/formatAmount'; import { useFeeCalculations } from './useFeeCalculations'; -import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; -import { RootState } from '../../../../../reducers'; import { useEthFiatAmount } from '../useEthFiatAmount'; import { useAccountNativeBalance } from '../useAccountNativeBalance'; import { useMemo } from 'react'; +import { useNativeCurrencySymbol } from '../useNativeCurrencySymbol'; export const RATE_WEI_NATIVE = '0xDE0B6B3A7640000'; // 1x10^18 @@ -26,14 +24,20 @@ export function useGasFeeToken({ tokenAddress }: { tokenAddress?: Hex }) { const locale = I18n.locale; const nativeFeeToken = useNativeGasFeeToken(); - const { gasFeeTokens, chainId } = transactionMeta || {}; + const { gasFeeTokens, chainId, excludeNativeTokenForFee } = + transactionMeta || {}; let gasFeeToken = gasFeeTokens?.find( (token) => token.tokenAddress.toLowerCase() === tokenAddress?.toLowerCase(), ); if (!gasFeeToken) { - gasFeeToken = nativeFeeToken; + // If `excludeNativeTokenForFee` is set to true, we select any available fee token + // if available instead of the native token. + gasFeeToken = + excludeNativeTokenForFee && gasFeeTokens && gasFeeTokens.length > 0 + ? gasFeeTokens[0] + : nativeFeeToken; } const { @@ -112,8 +116,8 @@ function useNativeGasFeeToken(): GasFeeToken { : ({ txParams: {} } as TransactionMeta), ); - const networkConfiguration = useSelector((state: RootState) => - selectNetworkConfigurationByChainId(state, transactionMeta?.chainId), + const { nativeCurrencySymbol } = useNativeCurrencySymbol( + transactionMeta?.chainId, ); const { balanceWeiInHex: balance } = useAccountNativeBalance( @@ -121,7 +125,6 @@ function useNativeGasFeeToken(): GasFeeToken { transactionMeta?.txParams?.from as string, ); - const { nativeCurrency } = networkConfiguration ?? {}; const { gas, maxFeePerGas, maxPriorityFeePerGas } = txParams ?? {}; return useMemo( @@ -135,7 +138,7 @@ function useNativeGasFeeToken(): GasFeeToken { maxPriorityFeePerGas: maxPriorityFeePerGas as Hex, rateWei: RATE_WEI_NATIVE, recipient: NATIVE_TOKEN_ADDRESS, - symbol: nativeCurrency, + symbol: nativeCurrencySymbol, tokenAddress: NATIVE_TOKEN_ADDRESS, }), [ @@ -144,7 +147,7 @@ function useNativeGasFeeToken(): GasFeeToken { gas, maxFeePerGas, maxPriorityFeePerGas, - nativeCurrency, + nativeCurrencySymbol, ], ); } diff --git a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts index f9df235e06d8..0514df8cd6f7 100644 --- a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts +++ b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts @@ -37,7 +37,12 @@ export const GAS_FEE_TOKEN_MOCK: GasFeeToken = { function getState({ gasFeeTokens, selectedGasFeeToken, -}: { gasFeeTokens?: GasFeeToken[]; selectedGasFeeToken?: Hex } = {}): { + excludeNativeTokenForFee, +}: { + gasFeeTokens?: GasFeeToken[]; + selectedGasFeeToken?: Hex; + excludeNativeTokenForFee?: Boolean; +} = {}): { state: ProviderValues['state']; } { const state = merge({}, contractDeploymentTransactionStateMock, { @@ -50,6 +55,7 @@ function getState({ address: FROM_MOCK, gasFeeTokens: gasFeeTokens ?? [GAS_FEE_TOKEN_MOCK], selectedGasFeeToken, + excludeNativeTokenForFee, }, ], }, @@ -72,11 +78,17 @@ function getState({ function runHook({ gasFeeTokens, selectedGasFeeToken, + excludeNativeTokenForFee, }: { gasFeeTokens?: GasFeeToken[]; selectedGasFeeToken?: Hex; + excludeNativeTokenForFee?: Boolean; } = {}) { - const { state } = getState({ gasFeeTokens, selectedGasFeeToken }); + const { state } = getState({ + gasFeeTokens, + selectedGasFeeToken, + excludeNativeTokenForFee, + }); const result = renderHookWithProvider(useAutomaticGasFeeTokenSelect, { state, @@ -123,6 +135,28 @@ describe('useAutomaticGasFeeTokenSelect', () => { expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(0); }); + it('selects first gas fee token if gas fee token already selected but doesnt correspond to any gasFeeTokens (only if `excludeNativeTokenForFee` is set', () => { + runHook({ + selectedGasFeeToken: GAS_FEE_TOKEN_MOCK.tokenAddress, + gasFeeTokens: [ + { + // When a gasFeeToken is available but is not the same as `selectedGasFeeToken`. + tokenAddress: NATIVE_TOKEN_ADDRESS, + } as unknown as GasFeeToken, + { + // When a gasFeeToken is available but is not the same as `selectedGasFeeToken`. + tokenAddress: '0x9876543210000000000000000000000000000000', + } as unknown as GasFeeToken, + ], + excludeNativeTokenForFee: true, + }); + expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(1); + expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledWith( + expect.any(String), + '0x9876543210000000000000000000000000000000', + ); + }); + it('does not select first gas fee token if no gas fee tokens', () => { runHook({ gasFeeTokens: [] }); expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(0); diff --git a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts index 3f9ab81eed1c..c42ee94051c3 100644 --- a/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts +++ b/app/components/Views/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts @@ -19,13 +19,17 @@ export function useAutomaticGasFeeTokenSelect() { gasFeeTokens, id: transactionId, selectedGasFeeToken, + excludeNativeTokenForFee, } = transactionMeta; const [first, second] = gasFeeTokens || []; - const firstGasFeeTokenAddress = - !isSmartTransaction && first?.tokenAddress === NATIVE_TOKEN_ADDRESS - ? second?.tokenAddress - : first?.tokenAddress; + const shouldSkipNativeToken = + first?.tokenAddress === NATIVE_TOKEN_ADDRESS && + (!isSmartTransaction || excludeNativeTokenForFee); + + const firstGasFeeTokenAddress = shouldSkipNativeToken + ? second?.tokenAddress + : first?.tokenAddress; const selectFirstToken = useCallback(() => { if (!transactionId || !firstGasFeeTokenAddress) { @@ -34,12 +38,26 @@ export function useAutomaticGasFeeTokenSelect() { updateSelectedGasFeeToken(transactionId, firstGasFeeTokenAddress); }, [transactionId, firstGasFeeTokenAddress]); + /** + * Selecting first gas fee token when `selectedGasFeeToken` is set but + * actually doesn't exist in the gasFeeTokens list. + * Since this logic is introduced with Tempo we use `excludeNativeTokenForFee` + * (only be set for Tempo as of now) to reduce regression risks. + */ + const hasSelectedGasFeeTokenNotInList = + excludeNativeTokenForFee && + selectedGasFeeToken && + !gasFeeTokens?.find( + ({ tokenAddress }) => + tokenAddress.toLocaleLowerCase() === + selectedGasFeeToken.toLocaleLowerCase(), + ); + const shouldSelect = + Boolean(firstGasFeeTokenAddress) && !checked && - isGaslessSupported && - hasInsufficientBalance && - !selectedGasFeeToken && - Boolean(firstGasFeeTokenAddress); + ((isGaslessSupported && hasInsufficientBalance && !selectedGasFeeToken) || + hasSelectedGasFeeTokenNotInList); useEffect(() => { if (shouldSelect) { diff --git a/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts index 40e0b0b82b4e..40456bbb90ea 100644 --- a/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts +++ b/app/components/Views/confirmations/hooks/useHasInsufficientBalance.ts @@ -1,14 +1,13 @@ import { add0x, Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; -import { useSelector } from 'react-redux'; -import { selectNetworkConfigurations } from '../../../../selectors/networkController'; import { addHexes, decimalToHex, multiplyHexes, } from '../../../../util/conversions'; import { useAccountNativeBalance } from './useAccountNativeBalance'; +import { useNativeCurrencySymbol } from './useNativeCurrencySymbol'; const HEX_ZERO = '0x0'; @@ -17,7 +16,6 @@ export function useHasInsufficientBalance(): { nativeCurrency?: string; } { const transactionMetadata = useTransactionMetadataRequest(); - const networkConfigurations = useSelector(selectNetworkConfigurations); const { balanceWeiInHex } = useAccountNativeBalance( transactionMetadata?.chainId as Hex, transactionMetadata?.txParams?.from as string, @@ -25,9 +23,9 @@ export function useHasInsufficientBalance(): { const { txParams } = transactionMetadata ?? {}; const { maxFeePerGas, gas, gasPrice } = txParams || {}; - const { nativeCurrency } = - networkConfigurations[transactionMetadata?.chainId as Hex] ?? {}; - + const { nativeCurrencySymbol: nativeCurrency } = useNativeCurrencySymbol( + transactionMetadata?.chainId, + ); const maxFeeNativeInHex = multiplyHexes( maxFeePerGas ? (decimalToHex(maxFeePerGas) as Hex) : (gasPrice as Hex), gas as Hex, diff --git a/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.test.ts b/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.test.ts new file mode 100644 index 000000000000..43db9db6eca9 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { CaipChainId, Hex } from '@metamask/utils'; +import { useNativeCurrencySymbol } from './useNativeCurrencySymbol'; +import { selectNetworkConfigurations } from '../../../../selectors/networkController'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../selectors/networkController', () => ({ + selectNetworkConfigurations: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const renderWithMock = ({ + chainId, + mockGetAllMultichainNetworkConfigurations = { + '0x1': { nativeCurrency: 'FOO' }, + '0x2': { nativeCurrency: 'BAR' }, + '0x1079': { nativeCurrency: 'USD' }, // Tempo Mainnet + '0xa5bf': { nativeCurrency: 'USD' }, // Tempo Testnet Moderato + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { nativeCurrency: 'MEME' }, + }, +}: { + chainId?: Hex | CaipChainId; + mockGetAllMultichainNetworkConfigurations?: { + [key: string]: { nativeCurrency: string }; + }; +}) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectNetworkConfigurations) { + return mockGetAllMultichainNetworkConfigurations; + } + }); + return renderHook(() => useNativeCurrencySymbol(chainId)); +}; + +describe('useNativeCurrencySymbol', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('returns FOO when network is found in map using hex chainId', () => { + const { result } = renderWithMock({ chainId: '0x1' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'FOO' }); + }); + + it('returns BAR when network is found in map using hex chainId', () => { + const { result } = renderWithMock({ chainId: '0x2' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'BAR' }); + }); + + it('returns ETH when network is missing from map hex chainId', () => { + const { result } = renderWithMock({ chainId: '0x3' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'ETH' }); + }); + + it('returns MEME when network is found in map using CAIP chainId', () => { + const { result } = renderWithMock({ + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'MEME' }); + }); + + it('returns ETH when network is missing from map using CAIP chainId', () => { + const { result } = renderWithMock({ chainId: 'eip155:3' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'ETH' }); + }); + + it('returns ETH when chainId is undefined', () => { + const { result } = renderWithMock({ chainId: undefined }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'ETH' }); + }); + + it('returns pathUSD when chainId is Hex Tempo Mainnet chainId', () => { + const { result } = renderWithMock({ chainId: '0x1079' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'pathUSD' }); + }); + + it('returns pathUSD when chainId is Hex Tempo Testnet chainId', () => { + const { result } = renderWithMock({ chainId: '0xa5bf' }); + expect(result.current).toEqual({ nativeCurrencySymbol: 'pathUSD' }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.ts b/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.ts new file mode 100644 index 000000000000..67201b42ffe8 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useNativeCurrencySymbol.ts @@ -0,0 +1,42 @@ +import { CaipChainId, Hex } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { selectNetworkConfigurations } from '../../../../selectors/networkController'; + +const currencySymbolOverrides: { + [key: Hex | CaipChainId]: string; +} = { + // Tempo Mainnet (no native, local config 'USD' but we prefer to display 'pathUSD') + 'eip155:4217': 'pathUSD', + // Tempo Testnet (no native, local config 'USD' but we prefer to display 'pathUSD') + 'eip155:42431': 'pathUSD', +}; + +/** + * This hook is meant to allow collecting the native currency of a given chain ensuring: + * - "Multichain" compatibility (non-EVM included). + * - Allowing overrides so we display a given native token regardless of user local config. + * + * @param chainId - chainId, either in Hex format (0x1079) or Caip format (solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp). + * Note that Caip is always used for "non-EVM" chains and EVM will be called with the Hex version. + * Overrides are stored as CAIP to ensure agnosticity of the format. + * @returns the native symbol to be displayed to the user for that chain. + */ +export const useNativeCurrencySymbol = (chainId?: Hex | CaipChainId) => { + const networkConfigurations = useSelector(selectNetworkConfigurations); + return useMemo(() => { + // chainId can be undefined during view transistions which escapes inference. + // 'ETH' is often used as fallback for native token symbol. + if (!chainId) { + return { nativeCurrencySymbol: 'ETH' }; + } + const caipChainId = ( + chainId.startsWith('0x') ? `eip155:${parseInt(chainId, 16)}` : chainId + ) as CaipChainId; + const nativeCurrencySymbol = + currencySymbolOverrides[caipChainId] ?? + networkConfigurations[chainId]?.nativeCurrency ?? + 'ETH'; + return { nativeCurrencySymbol }; + }, [chainId, networkConfigurations]); +}; diff --git a/app/constants/network.js b/app/constants/network.js index 412199462f48..8e53bccb268c 100644 --- a/app/constants/network.js +++ b/app/constants/network.js @@ -67,6 +67,8 @@ export const NETWORKS_CHAIN_ID = { PLASMA: toHex('9745'), CRONOS: toHex('25'), NOMINA: toHex('166'), + TEMPO_MAINNET: toHex('4217'), + TEMPO_TESTNET_MODERATO: toHex('42431'), }; // To add a deprecation warning to a network, add it to the array @@ -107,6 +109,8 @@ export const CHAINLIST_CURRENCY_SYMBOLS_MAP = { CRONOS: 'CRO', HYPE: 'HYPE', NOMINA: 'NOM', + TEMPO_MAINNET: 'USD', + TEMPO_TESTNET_MODERATO: 'USD', }; export const CURRENCY_SYMBOL_BY_CHAIN_ID = { @@ -144,6 +148,11 @@ export const CURRENCY_SYMBOL_BY_CHAIN_ID = { [NETWORKS_CHAIN_ID.CRONOS]: CHAINLIST_CURRENCY_SYMBOLS_MAP.CRONOS, [NETWORKS_CHAIN_ID.HYPER_EVM]: CHAINLIST_CURRENCY_SYMBOLS_MAP.HYPE, [NETWORKS_CHAIN_ID.NOMINA]: CHAINLIST_CURRENCY_SYMBOLS_MAP.NOMINA, + // Tempo (no native token) might be pre-filled with USD as main currency instead of PATHUSD + [NETWORKS_CHAIN_ID.TEMPO_MAINNET]: + CHAINLIST_CURRENCY_SYMBOLS_MAP.TEMPO_MAINNET, + [NETWORKS_CHAIN_ID.TEMPO_TESTNET_MODERATO]: + CHAINLIST_CURRENCY_SYMBOLS_MAP.TEMPO_TESTNET_MODERATO, }; export const TEST_NETWORK_IDS = [ @@ -154,6 +163,7 @@ export const TEST_NETWORK_IDS = [ NETWORKS_CHAIN_ID.MEGAETH_TESTNET, NETWORKS_CHAIN_ID.MEGAETH_TESTNET_V2, NETWORKS_CHAIN_ID.MONAD_TESTNET, + NETWORKS_CHAIN_ID.TEMPO_TESTNET_MODERATO, ]; export const TESTNET_CAIP_IDS = TEST_NETWORK_IDS.map((id) => diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js index b0ec091259d3..cdea888c80a3 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.js +++ b/app/core/BackgroundBridge/BackgroundBridge.js @@ -102,6 +102,7 @@ import { createTrustSignalsMiddleware } from '../RPCMethods/TrustSignalsMiddlewa import createDupeReqFilterStream from './createDupeReqFilterStream'; import { asLegacyMiddleware } from '@metamask/json-rpc-engine/v2'; import { createWalletSnapPermissionMiddleware } from '@metamask/snaps-rpc-methods'; +import { getAddTransactionSendCallExtraOptions } from '../../util/tempo/tempo-tx-utils'; const legacyNetworkId = () => { const { networksMetadata, selectedNetworkClientId } = @@ -875,14 +876,26 @@ export class BackgroundBridge extends EventEmitter { processSendCalls: processSendCalls.bind( null, { - addTransaction: - Engine.context.TransactionController.addTransaction.bind( - Engine.context.TransactionController, - ), - addTransactionBatch: - Engine.context.TransactionController.addTransactionBatch.bind( - Engine.context.TransactionController, - ), + addTransaction: async (txParams, options) => + Engine.context.TransactionController.addTransaction(txParams, { + ...options, + ...(await getAddTransactionSendCallExtraOptions({ + keyringController: Engine.context.KeyringController, + networkController: Engine.context.NetworkController, + networkClientId: options.networkClientId, + from: txParams.from, + })), + }), + addTransactionBatch: async (request) => + Engine.context.TransactionController.addTransactionBatch({ + ...request, + ...(await getAddTransactionSendCallExtraOptions({ + keyringController: Engine.context.KeyringController, + networkController: Engine.context.NetworkController, + networkClientId: request.networkClientId, + from: request.from, + })), + }), getDismissSmartAccountSuggestionEnabled: () => Engine.context.PreferencesController.state .dismissSmartAccountSuggestionEnabled, diff --git a/app/images/image-icons.js b/app/images/image-icons.js index 0176f26a5954..ec7f7aa30c70 100644 --- a/app/images/image-icons.js +++ b/app/images/image-icons.js @@ -60,6 +60,7 @@ import BOB from './bob.png'; import ROOTSTOCK from './rootstock-native.png'; import STABLE from './stable.png'; import STABLE_NATIVE from './stable-native.png'; +import TEMPO_NATIVE from './tempo-native.png'; /// BEGIN:ONLY_INCLUDE_IF(tron) import TRON from './tron-logo.png'; /// END:ONLY_INCLUDE_IF @@ -136,4 +137,6 @@ export default { CHZ, STABLE, USDT0: STABLE_NATIVE, + USD: TEMPO_NATIVE, + PATHUSD: TEMPO_NATIVE, }; diff --git a/app/images/tempo-native.png b/app/images/tempo-native.png new file mode 100644 index 000000000000..d2c284fa04c8 Binary files /dev/null and b/app/images/tempo-native.png differ diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 4243a103d73d..586021da600a 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -584,6 +584,7 @@ export function compareRpcUrls(rpcOne, rpcTwo) { */ const BLOCK_EXPLORER_NAME_OVERRIDES = { 'megaeth.blockscout.com': 'MegaETH Explorer', + 'explore.tempo.xyz': 'Tempo Explorer', }; /** diff --git a/app/util/tempo/tempo-tx-utils.test.ts b/app/util/tempo/tempo-tx-utils.test.ts new file mode 100644 index 000000000000..8971584737bf --- /dev/null +++ b/app/util/tempo/tempo-tx-utils.test.ts @@ -0,0 +1,298 @@ +import { Hex } from '@metamask/utils'; +import { TransactionParams } from '@metamask/transaction-controller'; +import { + buildBatchTransactionsFromTempoTransactionCalls, + checkIsValidTempoTransaction, + getAddTransactionSendCallExtraOptions, + getTempoExtraOptionsForChain, + isTempoChain, + isTempoTransactionType, +} from './tempo-tx-utils'; +import { KeyringController } from '@metamask/keyring-controller'; +import { NetworkController } from '@metamask/network-controller'; +import { accountSupports7702 } from '../transactions/account-supports-7702'; + +const MOCK_FEE_TOKEN = '0x20c000000000000000000000b9537d11c60e8b50' as Hex; +const MOCK_FROM = '0x13b7e6ebcd40777099e4c45d407745ab2de1d1f8' as Hex; +const MOCK_DEST_CONTRACT_ADDRESS = + '0x86fa047df5b69df0cbd6df566f1468756dcf339d' as Hex; +const MOCK_CALLDATA_1 = + '0xa9059cbb0000000000000000000000002367e6eca6e1fcc2d112133c896e3bddad375aff000000000000000000000000000000000000000000000000002386f26fc10000' as Hex; +const MOCK_CALLDATA_2 = + '0xa9059cbb0000000000000000000000001e3abc74428056924ceee2f45f060879c3f063ed000000000000000000000000000000000000000000000000002386f26fc10000' as Hex; +const MOCK_TEMPO_CALLS = [ + { + data: MOCK_CALLDATA_1, + to: MOCK_DEST_CONTRACT_ADDRESS, + value: '0x' as const, + }, + { + data: MOCK_CALLDATA_2, + to: MOCK_DEST_CONTRACT_ADDRESS, + value: '0x' as const, + }, +]; + +jest.mock('../transactions/account-supports-7702', () => ({ + accountSupports7702: jest.fn().mockResolvedValue(true), +})); + +describe('tempo-tx-utils', () => { + describe('isTempoTransactionType', () => { + it('returns true in case of tempo transaction type', () => { + expect( + isTempoTransactionType({ + from: MOCK_FROM, + calls: MOCK_TEMPO_CALLS, + type: '0x76', + } as unknown as TransactionParams), + ).toBe(true); + }); + + it('returns false in case of legacy transaction type', () => { + expect( + isTempoTransactionType({ + from: MOCK_FROM, + calls: MOCK_TEMPO_CALLS, + type: '0x02', + } as unknown as TransactionParams), + ).toBe(false); + }); + + it('returns false when no `type` field', () => { + expect( + isTempoTransactionType({ + from: MOCK_FROM, + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toBe(false); + }); + }); + + describe('checkIsValidTempoTransaction', () => { + it('returns successfuly in case of well-formed tempo transaction type', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: '0x76', + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).not.toThrow(); + }); + + it('throws when `type` not 0x76', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: '0x02', + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toThrow( + `Tempo Transaction: Transaction doesn't have Tempo transaction type (0x76)`, + ); + }); + + it('throws when `type` has invalid format', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: 118, + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toThrow( + `Tempo Transaction: Transaction doesn't have Tempo transaction type (0x76)`, + ); + }); + + it('throws when `type` is missing', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toThrow( + `Tempo Transaction: Transaction doesn't have Tempo transaction type (0x76)`, + ); + }); + + it('throws when `from` is missing', () => { + expect(() => + checkIsValidTempoTransaction({ + type: '0x76', + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toThrow(`Tempo Transaction: Missing or invalid field 'from'`); + }); + + it('throws when `from` is invalid', () => { + expect(() => + checkIsValidTempoTransaction({ + from: 1, + type: '0x76', + calls: MOCK_TEMPO_CALLS, + } as unknown as TransactionParams), + ).toThrow(`Tempo Transaction: Missing or invalid field 'from'`); + }); + + it('throws when `calls` is missing', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: '0x76', + } as unknown as TransactionParams), + ).toThrow(`Tempo Transaction: Missing or invalid field 'calls'`); + }); + + it('throws when `calls` is an invalid type', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: '0x76', + calls: { + foo: 'bar', + }, + } as unknown as TransactionParams), + ).toThrow(`Tempo Transaction: Missing or invalid field 'calls'`); + }); + + it('throws when `calls` is empty', () => { + expect(() => + checkIsValidTempoTransaction({ + from: MOCK_FROM, + type: '0x76', + calls: [], + } as unknown as TransactionParams), + ).toThrow(`Tempo Transaction: Missing or invalid field 'calls'`); + }); + }); + + describe('buildBatchTransactionsFromTempoTransactionCalls', () => { + it('extract batch-style params from Tempo transaction `calls` field', () => { + const tempoTxParams = { + calls: MOCK_TEMPO_CALLS, + feeToken: MOCK_FEE_TOKEN, + from: MOCK_FROM, + type: '0x76' as const, // Tempo in-house tx type. + }; + + const batchTxParams = + buildBatchTransactionsFromTempoTransactionCalls(tempoTxParams); + expect(batchTxParams).toEqual([ + { + params: { + data: MOCK_CALLDATA_1, + to: MOCK_DEST_CONTRACT_ADDRESS, + value: '0x0', + }, + }, + { + params: { + data: MOCK_CALLDATA_2, + to: MOCK_DEST_CONTRACT_ADDRESS, + value: '0x0', + }, + }, + ]); + }); + }); + + describe('isTempoChain', () => { + it('return true for Tempo Mainnet', () => { + expect(isTempoChain('0x1079')).toBe(true); + }); + it('return true for Tempo Moderato Testnet', () => { + expect(isTempoChain('0xa5bf')).toBe(true); + }); + it('return false for Polygon Mainnet', () => { + expect(isTempoChain('0x89')).toBe(false); + }); + }); + + describe('getTempoExtraOptionsForChain', () => { + it('return params for Tempo Mainnet', () => { + expect(getTempoExtraOptionsForChain('0x1079')).toEqual({ + excludeNativeTokenForFee: true, + gasFeeToken: '0x20c0000000000000000000000000000000000000', + }); + }); + it('return params for Tempo Moderato Testnet', () => { + expect(getTempoExtraOptionsForChain('0xa5bf')).toEqual({ + excludeNativeTokenForFee: true, + gasFeeToken: '0x20c0000000000000000000000000000000000000', + }); + }); + it('return an empty object for Polygon Mainnet', () => { + expect(getTempoExtraOptionsForChain('0x89')).toEqual({}); + }); + }); + + describe('addTransactionSendCallExtraOptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('non-reg: returns {} if controllers are uninitalized', async () => { + expect( + await getAddTransactionSendCallExtraOptions({ + keyringController: {} as KeyringController, + networkController: {} as NetworkController, + networkClientId: '123', + from: '0x123', + }), + ).toEqual({}); + expect(accountSupports7702).not.toHaveBeenCalled(); + }); + + it('non-reg: returns {} params if NOT Tempo chain', async () => { + expect( + await getAddTransactionSendCallExtraOptions({ + keyringController: {} as KeyringController, + networkController: { + getNetworkConfigurationByNetworkClientId: () => ({ + chainId: '0x1', // not Tempo + }), + } as unknown as NetworkController, + networkClientId: '123', + from: '0x123', + }), + ).toEqual({}); + expect(accountSupports7702).not.toHaveBeenCalled(); + }); + + it('tempo: returns {} params if Tempo chain but account doesnt support 7702', async () => { + (accountSupports7702 as jest.Mock).mockResolvedValueOnce(false); + expect( + await getAddTransactionSendCallExtraOptions({ + keyringController: {} as KeyringController, + networkController: { + getNetworkConfigurationByNetworkClientId: () => ({ + chainId: '0x1079', + }), + } as unknown as NetworkController, + networkClientId: '123', + from: '0x123', + }), + ).toEqual({}); + expect(accountSupports7702).toHaveBeenCalledTimes(1); + }); + + it('tempo: returns Tempo params if Tempo chain', async () => { + (accountSupports7702 as jest.Mock).mockResolvedValueOnce(true); + expect( + await getAddTransactionSendCallExtraOptions({ + keyringController: {} as KeyringController, + networkController: { + getNetworkConfigurationByNetworkClientId: () => ({ + chainId: '0x1079', + }), + } as unknown as NetworkController, + networkClientId: '123', + from: '0x123', + }), + ).toEqual({ + excludeNativeTokenForFee: true, + gasFeeToken: '0x20c0000000000000000000000000000000000000', + }); + expect(accountSupports7702).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/util/tempo/tempo-tx-utils.ts b/app/util/tempo/tempo-tx-utils.ts new file mode 100644 index 000000000000..45f925a28f61 --- /dev/null +++ b/app/util/tempo/tempo-tx-utils.ts @@ -0,0 +1,200 @@ +import { Hex } from '@metamask/utils'; +import { accountSupports7702 } from '../transactions/account-supports-7702'; +import { NetworkController } from '@metamask/network-controller'; +import { KeyringController } from '@metamask/keyring-controller'; +import Logger from '../../util/Logger'; +import { + TransactionParams, + TransactionController as BaseTransactionController, +} from '@metamask/transaction-controller'; +// Seem to be set by dApps only for batch calls. +// For single-txs, this type doesn't appear. +const TEMPO_TRANSACTION_TYPE: Hex = '0x76'; + +interface TempoConfig { + perChainConfig: { + [key: Hex]: { + defaultFeeToken: { symbol: string; address: Hex }; + }; + }; +} + +const TEMPO_CONFIG: TempoConfig = { + perChainConfig: { + '0x1079': { + defaultFeeToken: { + address: '0x20c0000000000000000000000000000000000000', + symbol: 'pathUSD', + }, + }, + '0xa5bf': { + defaultFeeToken: { + address: '0x20c0000000000000000000000000000000000000', + symbol: 'pathUSD', + }, + }, + }, +} as const; + +interface TempoCall { + to: Hex; + // In our tests we see '0x' (probably to signal no native token), + // However '0x' is invalid as a value and we use '0x0' when transforming. + value: Hex | '0x'; + data: Hex; +} + +interface TempoTransactionParams { + from: Hex; + type: '0x76'; + calls: TempoCall[]; + feeToken?: Hex; +} + +export function buildBatchTransactionsFromTempoTransactionCalls( + params: TempoTransactionParams, +) { + return params.calls.map(({ data, to }) => ({ + params: { + data, + to, + // Tempo Transactions 'calls' parameters differ in a least having '0x' + // (probably to signal absence of native token) instead of '0x0'. + value: '0x0' as Hex, + }, + })); +} + +const TEMPO_CHAINS = Object.keys(TEMPO_CONFIG.perChainConfig); + +export function isTempoChain(chainId: Hex) { + return TEMPO_CHAINS.includes(chainId); +} + +export function getTempoExtraOptionsForChain( + chainId: Hex, +): { gasFeeToken: Hex; excludeNativeTokenForFee: true } | {} { + const tempoConfigForChain = TEMPO_CONFIG.perChainConfig[chainId]; + if (!tempoConfigForChain) { + return {}; + } + return { + excludeNativeTokenForFee: true, + gasFeeToken: tempoConfigForChain.defaultFeeToken.address, + }; +} + +export function isTempoTransactionType(params: TransactionParams) { + return params.type === TEMPO_TRANSACTION_TYPE; +} + +export function checkIsValidTempoTransaction( + params: TransactionParams, +): asserts params is TempoTransactionParams { + if (!isTempoTransactionType(params)) { + throw new Error( + `Tempo Transaction: Transaction doesn't have Tempo transaction type (0x76)`, + ); + } + if (typeof params.from !== 'string' || !params.from.startsWith('0x')) { + throw new Error(`Tempo Transaction: Missing or invalid field 'from'`); + } + if ( + !('calls' in params) || + !Array.isArray(params.calls) || + params.calls.length === 0 + ) { + throw new Error(`Tempo Transaction: Missing or invalid field 'calls'`); + } +} + +export function getTempoEvmTransactionOptions({ + options, + chainId, +}: { + options: Parameters[1]; + chainId: Hex; +}) { + return { + ...options, + ...getTempoExtraOptionsForChain(chainId), + }; +} + +export function getTempoTransactionBatchArgs({ + transaction, + options, + chainId, +}: { + transaction: TransactionParams; + options: Parameters[1]; + chainId: Hex; +}) { + const chainTempoConfig = TEMPO_CONFIG.perChainConfig[chainId]; + if (!chainTempoConfig) { + throw new Error(`Tempo transactions not supported for chain: ${chainId}`); + } + checkIsValidTempoTransaction(transaction); + return { + ...options, + from: transaction.from, + transactions: buildBatchTransactionsFromTempoTransactionCalls(transaction), + // If no token is provided, we force a default one so we don't fall in + // fee preference algo: https://docs.tempo.xyz/protocol/fees/spec-fee#fee-token-preferences + gasFeeToken: + transaction.feeToken || chainTempoConfig.defaultFeeToken.address, + excludeNativeTokenForFee: true, + }; +} + +export async function getAddTransactionSendCallExtraOptions({ + networkClientId, + from, + networkController, + keyringController, +}: { + networkClientId: string; + from: string; + networkController: NetworkController; + keyringController: KeyringController; +}) { + /** + * Gets chain-specific parameters that need to be injected in addTransaction/addTransactionBatch. + * Done initially for Tempo. + * Done gracefully - silencing errors - so it doesn't impact previous behavior. + * Skipped in case of account not supporting EIP-7702, such as hardware wallets. + */ + try { + const networkConfiguration = + networkController.getNetworkConfigurationByNetworkClientId( + networkClientId, + ); + if (!networkConfiguration) { + Logger.log( + `addTransactionSendCallExtraOptions: No networkConfiguration for networkClientId ${networkClientId}`, + ); + return {}; + } + const { chainId: currentRequestChainId } = networkConfiguration; + if (!isTempoChain(currentRequestChainId)) { + return {}; + } + const isEip7702SupportedByAccount = await accountSupports7702( + from, + keyringController, + ); + if (!isEip7702SupportedByAccount) { + Logger.log( + 'addTransactionSendCallExtraOptions: Tempo chain but wallet does not support 7702. Falling back to legacy transactions', + ); + return {}; + } + return getTempoExtraOptionsForChain(currentRequestChainId); + } catch (err) { + Logger.log( + 'addTransactionSendCallExtraOptions: Error while getting addTransaction extra options', + err, + ); + return {}; + } +} diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts index e34e219d083d..fedfe9a8a8de 100644 --- a/app/util/transaction-controller/index.test.ts +++ b/app/util/transaction-controller/index.test.ts @@ -11,6 +11,14 @@ import * as TransactionControllerUtils from './index'; import Engine from '../../core/Engine'; import { store } from '../../store'; import { RootState } from '../../reducers'; +import { Hex } from '@metamask/utils'; +import { checkIsValidTempoTransaction } from '../tempo/tempo-tx-utils'; +import { accountSupports7702 } from '../transactions/account-supports-7702'; + +jest.mock('../tempo/tempo-tx-utils', () => ({ + ...jest.requireActual('../tempo/tempo-tx-utils'), + checkIsValidTempoTransaction: jest.fn(), +})); const { addTransaction, @@ -18,6 +26,7 @@ const { getNetworkNonce, estimateGasFee, getPreviousGasFromController, + getChainIdFromNetworkClientId, ...proxyMethods } = TransactionControllerUtils; @@ -29,6 +38,10 @@ jest.mock('../../store', () => ({ }, })); +jest.mock('../transactions/account-supports-7702', () => ({ + accountSupports7702: jest.fn().mockResolvedValue(true), +})); + const ID_MOCK = 'testId'; const EIP_1559_TRANSACTION_PARAMS_MOCK = { @@ -77,6 +90,55 @@ const TRANSACTION_OPTIONS_MOCK = { origin: 'origin', }; +const TEMPO_VALID_CHAIN_ID = '0xa5bf' as Hex; +const BATCHID_MOCK = '0xmockBatchId' as Hex; +const FROM_FIELD_MOCK = '0x1'; + +const TEMPO_VALID_CALLS_FIELD_MOCK = [ + { + data: '0xa9059cbb0000000000000000000000002367e6eca6e1fcc2d112133c896e3bddad375aff000000000000000000000000000000000000000000000000002386f26fc10000', + to: '0x86fA047df5b69df0CBD6dF566F1468756dCF339D', + value: '0x', + }, + { + data: '0xa9059cbb0000000000000000000000001e3abc74428056924ceee2f45f060879c3f063ed000000000000000000000000000000000000000000000000002386f26fc10000', + to: '0x86fA047df5b69df0CBD6dF566F1468756dCF339D', + value: '0x', + }, +]; + +const TEMPO_EXPECTED_TRANSACTIONS_FOR_VALID_CALLS_FIELD = [ + { + params: { + data: '0xa9059cbb0000000000000000000000002367e6eca6e1fcc2d112133c896e3bddad375aff000000000000000000000000000000000000000000000000002386f26fc10000', + to: '0x86fA047df5b69df0CBD6dF566F1468756dCF339D', + value: '0x0', + }, + }, + { + params: { + data: '0xa9059cbb0000000000000000000000001e3abc74428056924ceee2f45f060879c3f063ed000000000000000000000000000000000000000000000000002386f26fc10000', + to: '0x86fA047df5b69df0CBD6dF566F1468756dCF339D', + value: '0x0', + }, + }, +]; + +const TEMPO_FEE_TOKEN_MOCK = '0xtempoFeeToken'; + +const TEMPO_TRANSACTION_PARAMS_MOCK = { + type: '0x76' as const, + from: FROM_FIELD_MOCK, + feeToken: TEMPO_FEE_TOKEN_MOCK, + calls: TEMPO_VALID_CALLS_FIELD_MOCK, +}; + +const BATCH_TRANSACTION_META_MOCK: TransactionMeta = { + id: 'batchTestId', + hash: 'batchTestHash', + batchId: BATCHID_MOCK, +} as TransactionMeta; + jest.mock('../../core/Engine', () => ({ context: { TransactionController: { @@ -104,10 +166,28 @@ jest.mock('../../core/Engine', () => ({ updateRequiredTransactionIds: jest.fn(), isAtomicBatchSupported: jest.fn(), }, + NetworkController: { + getNetworkConfigurationByNetworkClientId: jest.fn(), + }, }, })); describe('Transaction Controller Util', () => { + beforeEach(() => { + jest + .mocked( + Engine.context.NetworkController + .getNetworkConfigurationByNetworkClientId, + ) + .mockReturnValue({ + chainId: '0x1', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [], + }); + }); afterEach(() => { jest.clearAllMocks(); }); @@ -128,6 +208,136 @@ describe('Transaction Controller Util', () => { }); }); + describe('addTransaction when transacton is from Tempo chain', () => { + beforeEach(() => { + (accountSupports7702 as jest.Mock).mockResolvedValue(true); + jest + .mocked( + Engine.context.NetworkController + .getNetworkConfigurationByNetworkClientId, + ) + .mockReturnValue({ + chainId: TEMPO_VALID_CHAIN_ID, + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + name: 'Tempo Testnet', + nativeCurrency: 'USD', + rpcEndpoints: [], + }); + }); + + afterEach(() => { + (accountSupports7702 as jest.Mock).mockReset(); + jest + .mocked( + Engine.context.NetworkController + .getNetworkConfigurationByNetworkClientId, + ) + .mockReset(); + ( + Engine.context.TransactionController.addTransactionBatch as jest.Mock + ).mockReset(); + ( + Engine.context.TransactionController.getTransactions as jest.Mock + ).mockReset(); + }); + + it('calls regular addTransaction with extra params when transaction is NOT type 0x76', async () => { + jest + .mocked(Engine.context.TransactionController.addTransactionBatch) + .mockReturnValueOnce( + Promise.resolve({ + batchId: BATCHID_MOCK, + }), + ); + jest + .mocked(Engine.context.TransactionController.getTransactions) + .mockReturnValueOnce([BATCH_TRANSACTION_META_MOCK]); + + await addTransaction( + EIP_1559_TRANSACTION_PARAMS_MOCK, + TRANSACTION_OPTIONS_MOCK, + ); + expect( + Engine.context.TransactionController.addTransaction, + ).toHaveBeenCalledWith(EIP_1559_TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + excludeNativeTokenForFee: true, + gasFeeToken: '0x20c0000000000000000000000000000000000000', + }); + expect( + Engine.context.TransactionController.addTransactionBatch, + ).not.toHaveBeenCalled(); + }); + + it('calls addTransactionBatch when transaction is type 0x76', async () => { + jest + .mocked(Engine.context.TransactionController.addTransactionBatch) + .mockReturnValueOnce( + Promise.resolve({ + batchId: BATCHID_MOCK, + }), + ); + jest + .mocked(Engine.context.TransactionController.getTransactions) + .mockReturnValueOnce([BATCH_TRANSACTION_META_MOCK]); + + const result = await addTransaction( + TEMPO_TRANSACTION_PARAMS_MOCK, + TRANSACTION_OPTIONS_MOCK, + ); + expect( + Engine.context.TransactionController.addTransactionBatch, + ).toHaveBeenCalledTimes(1); + expect( + Engine.context.TransactionController.addTransactionBatch, + ).toHaveBeenCalledWith( + expect.objectContaining({ + networkClientId: TRANSACTION_OPTIONS_MOCK.networkClientId, + origin: TRANSACTION_OPTIONS_MOCK.origin, + from: FROM_FIELD_MOCK, + gasFeeToken: TEMPO_FEE_TOKEN_MOCK, + excludeNativeTokenForFee: true, + transactions: TEMPO_EXPECTED_TRANSACTIONS_FOR_VALID_CALLS_FIELD, + }), + ); + expect(result).toEqual({ + result: Promise.resolve(BATCH_TRANSACTION_META_MOCK.hash), + transactionMeta: BATCH_TRANSACTION_META_MOCK, + }); + }); + + it('does not call addTransactionBatch if type 0x76 and addTransactionBatch throws', async () => { + ( + Engine.context.TransactionController.addTransactionBatch as jest.Mock + ).mockImplementationOnce(() => { + throw new Error('Tempo Transaction: Mock error'); + }); + await expect( + addTransaction(TEMPO_TRANSACTION_PARAMS_MOCK, TRANSACTION_OPTIONS_MOCK), + ).rejects.toThrow('Tempo Transaction: Mock error'); + expect( + Engine.context.TransactionController.addTransactionBatch, + ).toHaveBeenCalledTimes(1); + expect( + Engine.context.TransactionController.getTransactions, + ).not.toHaveBeenCalled(); + }); + + it('does not call addTransactionBatch if accountSupports7702 resolves to false', async () => { + (accountSupports7702 as jest.Mock).mockResolvedValueOnce(false); + await expect( + addTransaction(TEMPO_TRANSACTION_PARAMS_MOCK, TRANSACTION_OPTIONS_MOCK), + ).rejects.toThrow(`Wallet not supported for Tempo Transactions.`); + expect( + Engine.context.TransactionController.addTransactionBatch, + ).not.toHaveBeenCalled(); + expect( + Engine.context.TransactionController.getTransactions, + ).not.toHaveBeenCalled(); + }); + }); + describe('estimateGas', () => { it('should call estimateGas with correct parameters', async () => { await estimateGas( diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts index 36949b999113..f022c842cc68 100644 --- a/app/util/transaction-controller/index.ts +++ b/app/util/transaction-controller/index.ts @@ -6,19 +6,125 @@ import { TransactionController as BaseTransactionController, IsAtomicBatchSupportedRequest, IsAtomicBatchSupportedResult, + Result, } from '@metamask/transaction-controller'; import { NetworkClientId } from '@metamask/network-controller'; import Engine from '../../core/Engine'; import { selectBasicFunctionalityEnabled } from '../../selectors/settings'; import { store } from '../../store'; +import { + getTempoEvmTransactionOptions, + getTempoTransactionBatchArgs, + isTempoChain, + isTempoTransactionType, +} from '../tempo/tempo-tx-utils'; +import { accountSupports7702 } from '../transactions/account-supports-7702'; +import Logger from '../Logger'; + +// Making the function graceful to avoid regression risks. +export function getChainIdFromNetworkClientId( + networkClientId: string, +): Hex | undefined { + try { + const { NetworkController } = Engine.context; + const networkConfig = + NetworkController.getNetworkConfigurationByNetworkClientId( + networkClientId, + ); + if (!networkConfig) { + return undefined; + } + const { chainId } = networkConfig; + return chainId; + } catch (err) { + Logger.log('Unable to get chain id from neworkClientId', networkClientId); + return undefined; + } +} + +async function addTempoTransaction({ + transaction, + options, + chainId, +}: { + transaction: TransactionParams; + options: Parameters[1]; + chainId: Hex; +}): Promise { + const { KeyringController, TransactionController } = Engine.context; + const isEip7702SupportedByAccount = await accountSupports7702( + transaction.from, + KeyringController as Parameters[1], + ); + // Classic transaction, we simply set pathUSD as default + // and add excludeNativeTokenForFee to signal to ignore native. + // We enter this flow is dApp non-0x76 txs as well as send flow. + if (!isTempoTransactionType(transaction)) { + if (!isEip7702SupportedByAccount) { + Logger.log( + 'addTransactionOnTempo: Tempo chain but wallet does not support 7702. Falling back to legacy transactions', + ); + return TransactionController.addTransaction(transaction, options); + } + return TransactionController.addTransaction( + transaction, + getTempoEvmTransactionOptions({ + options, + chainId, + }), + ); + } else if (!isEip7702SupportedByAccount) { + throw new Error('Wallet not supported for Tempo Transactions.'); + } + + const result = await TransactionController.addTransactionBatch( + getTempoTransactionBatchArgs({ + transaction, + options, + chainId, + }), + ); + + const { batchId } = result; + const transactionMeta = TransactionController?.getTransactions({ + searchCriteria: { batchId }, + })?.[0]; + + if (!transactionMeta) { + Logger.log( + `Batch submitted with id ${batchId} but no matching transaction found in transactionController.`, + ); + throw new Error( + 'Tempo Transaction: Unable to determine if transaction was successful.', + ); + } + + if (!transactionMeta.hash) { + Logger.log( + `Batch submitted with id ${batchId} but transaction found in transactionController does not have a hash.`, + ); + throw new Error( + 'Tempo Transaction: Unable to determine if transaction was successful.', + ); + } + + return { + transactionMeta, + result: Promise.resolve(transactionMeta.hash), + }; +} export async function addTransaction( transaction: TransactionParams, opts: Parameters[1], ) { - const { TransactionController } = Engine.context; + const chainId = getChainIdFromNetworkClientId(opts.networkClientId); + if (chainId && isTempoChain(chainId)) { + return await addTempoTransaction({ transaction, options: opts, chainId }); + } + const { TransactionController } = Engine.context; return await TransactionController.addTransaction(transaction, opts); } diff --git a/package.json b/package.json index e9f34f68dde5..9f1ccdc8e117 100644 --- a/package.json +++ b/package.json @@ -224,8 +224,8 @@ "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", - "@metamask/assets-controller": "^3.2.1", - "@metamask/assets-controllers": "^103.0.0", + "@metamask/assets-controller": "^3.3.0", + "@metamask/assets-controllers": "^103.1.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.10.1", "@metamask/bridge-controller": "^70.0.0", @@ -236,7 +236,7 @@ "@metamask/controller-utils": "^11.18.0", "@metamask/core-backend": "^6.2.0", "@metamask/delegation-controller": "^2.0.2", - "@metamask/delegation-deployments": "^0.15.0", + "@metamask/delegation-deployments": "^1.0.0", "@metamask/design-system-react-native": "^0.13.0", "@metamask/design-system-twrnc-preset": "^0.4.1", "@metamask/design-tokens": "^8.3.0", @@ -323,7 +323,7 @@ "@metamask/storage-service": "^1.0.0", "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", - "@metamask/transaction-controller": "^63.3.1", + "@metamask/transaction-controller": "^64.0.0", "@metamask/transaction-pay-controller": "^19.1.0", "@metamask/tron-wallet-snap": "^1.25.1", "@metamask/utils": "^11.8.1", diff --git a/yarn.lock b/yarn.lock index 1d8a6d96df22..af951a33daa1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7703,18 +7703,18 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^3.2.1": - version: 3.2.1 - resolution: "@metamask/assets-controller@npm:3.2.1" +"@metamask/assets-controller@npm:^3.3.0": + version: 3.3.0 + resolution: "@metamask/assets-controller@npm:3.3.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "npm:^103.0.0" + "@metamask/assets-controllers": "npm:^103.1.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/client-controller": "npm:^1.0.1" - "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/controller-utils": "npm:^11.20.0" "@metamask/core-backend": "npm:^6.2.1" "@metamask/keyring-api": "npm:^21.6.0" "@metamask/keyring-controller": "npm:^25.1.1" @@ -7734,7 +7734,7 @@ __metadata: bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/017cb5d9546e468ad9890686c077d062fbf68a5a28f49f5fab091e9fbdd51b26d61d2b8f171fd04e9fbdd7e8eea2317d17b101c05244c91fd935afddbbf21102 + checksum: 10/14c95556b4f0f85072f30392aa1c688f9a9d4fc8bec7cd8c5ba079a054c7d704d44766cc00f887afe73eb6d304cd59c4d569f7633a0afa303f1babd85bef97ff languageName: node linkType: hard @@ -7810,7 +7810,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^103.0.0, @metamask/assets-controllers@npm:^103.1.1": +"@metamask/assets-controllers@npm:^103.1.0, @metamask/assets-controllers@npm:^103.1.1": version: 103.1.1 resolution: "@metamask/assets-controllers@npm:103.1.1" dependencies: @@ -8174,10 +8174,10 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-deployments@npm:^0.15.0": - version: 0.15.0 - resolution: "@metamask/delegation-deployments@npm:0.15.0" - checksum: 10/b37933e4b1c7d5cad5b6ec8861dc0b883eb4dd17cc29a9d804b3fbdb2c39aadb7ff47b8a42d1195b637eefbae15e7e3cea6bde3e7ff34f64eee11c1026767413 +"@metamask/delegation-deployments@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/delegation-deployments@npm:1.0.0" + checksum: 10/d81e3e875f6a2506f2e6302ba981a609a311c3a4388033ec7e23e45606f27163c6ee8dcd13b1dde7dd6e0eb0186af8b03616b4ff30b334451b8d60381ef8a108 languageName: node linkType: hard @@ -35543,8 +35543,8 @@ __metadata: "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" - "@metamask/assets-controller": "npm:^3.2.1" - "@metamask/assets-controllers": "npm:^103.0.0" + "@metamask/assets-controller": "npm:^3.3.0" + "@metamask/assets-controllers": "npm:^103.1.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.10.1" @@ -35559,7 +35559,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.18.0" "@metamask/core-backend": "npm:^6.2.0" "@metamask/delegation-controller": "npm:^2.0.2" - "@metamask/delegation-deployments": "npm:^0.15.0" + "@metamask/delegation-deployments": "npm:^1.0.0" "@metamask/design-system-react-native": "npm:^0.13.0" "@metamask/design-system-twrnc-preset": "npm:^0.4.1" "@metamask/design-tokens": "npm:^8.3.0" @@ -35655,7 +35655,7 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^63.3.1" + "@metamask/transaction-controller": "npm:^64.0.0" "@metamask/transaction-pay-controller": "npm:^19.1.0" "@metamask/tron-wallet-snap": "npm:^1.25.1" "@metamask/utils": "npm:^11.8.1"