diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index 628af4051ea8..92077647482e 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -142,6 +142,7 @@ describe('ConfirmFooter', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: false, + pending: false, }); useIsGaslessLoadingMock.mockReturnValue({ @@ -216,6 +217,7 @@ describe('ConfirmFooter', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useInsufficientBalanceAlertsMock.mockReturnValue(ALERT_MOCK); jest.spyOn(confirmContext, 'useConfirmContext').mockReturnValue({ diff --git a/ui/pages/confirmations/components/confirm/info/shared/gas-fee-token-modal/gas-fee-token-modal.test.tsx b/ui/pages/confirmations/components/confirm/info/shared/gas-fee-token-modal/gas-fee-token-modal.test.tsx index a92835556947..a0a575b2493d 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/gas-fee-token-modal/gas-fee-token-modal.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/gas-fee-token-modal/gas-fee-token-modal.test.tsx @@ -120,6 +120,7 @@ describe('GasFeeTokenModal', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); }); @@ -212,6 +213,7 @@ describe('GasFeeTokenModal', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: true, + pending: false, }); const result = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/info/shared/selected-gas-fee-token/selected-gas-fee-token.test.tsx b/ui/pages/confirmations/components/confirm/info/shared/selected-gas-fee-token/selected-gas-fee-token.test.tsx index 34b7bf1ade68..d70d9e56a173 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/selected-gas-fee-token/selected-gas-fee-token.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/selected-gas-fee-token/selected-gas-fee-token.test.tsx @@ -20,7 +20,6 @@ jest.mock('../../../../../hooks/gas/useIsGaslessSupported'); jest.mock( '../../../../../hooks/alerts/transactions/useInsufficientBalanceAlerts', ); - function getStore({ gasFeeTokens, noSelectedGasFeeToken, @@ -57,6 +56,7 @@ describe('SelectedGasFeeToken', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useInsufficientBalanceAlertsMock.mockReturnValue([ @@ -110,6 +110,7 @@ describe('SelectedGasFeeToken', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: false, + pending: false, }); const result = renderWithConfirmContextProvider( @@ -124,6 +125,7 @@ describe('SelectedGasFeeToken', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: true, + pending: false, }); const result = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts index e21a6ccf756b..18beeaf34f93 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts @@ -106,6 +106,7 @@ describe('useInsufficientBalanceAlerts', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: false, + pending: false, }); }); @@ -117,6 +118,7 @@ describe('useInsufficientBalanceAlerts', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: true, + pending: false, }); const alerts = runHook({ diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts index a69e16de554e..880559f14934 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts @@ -1,11 +1,8 @@ 'use no memo'; import { TransactionMeta } from '@metamask/transaction-controller'; -import { CaipChainId, Hex } from '@metamask/utils'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; - -import { sumHexes } from '../../../../../../shared/modules/conversion.utils'; import { AlertActionKey, RowAlertKey, @@ -13,15 +10,10 @@ import { import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; -import { - getMultichainNetworkConfigurationsByChainId, - getNativeTokenCachedBalanceByChainIdByAccountAddress, - getUseTransactionSimulations, - selectTransactionFeeById, -} from '../../../../../selectors'; +import { getUseTransactionSimulations } from '../../../../../selectors'; import { useConfirmContext } from '../../../context/confirm'; -import { isBalanceSufficient } from '../../../send-legacy/send.utils'; import { useIsGaslessSupported } from '../../gas/useIsGaslessSupported'; +import { useHasInsufficientBalance } from '../../useHasInsufficientBalance'; export function useInsufficientBalanceAlerts({ ignoreGasFeeToken, @@ -30,62 +22,41 @@ export function useInsufficientBalanceAlerts({ } = {}): Alert[] { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); - const { - id: transactionId, - chainId, - selectedGasFeeToken, - gasFeeTokens, - txParams: { value = '0x0', from: fromAddress = '' } = {}, - } = currentConfirmation ?? {}; - - const batchTransactionValues = - currentConfirmation?.nestedTransactions?.map( - (trxn) => (trxn.value as Hex) ?? 0x0, - ) ?? []; - + const { selectedGasFeeToken, gasFeeTokens } = currentConfirmation ?? {}; + const { hasInsufficientBalance, nativeCurrency } = + useHasInsufficientBalance(); const isSimulationEnabled = useSelector(getUseTransactionSimulations); + const isSponsored = currentConfirmation?.isGasFeeSponsored; + const { + isSupported: isGaslessSupported, + pending: isGaslessSupportedPending, + } = useIsGaslessSupported(); - const chainBalances = useSelector((state) => - getNativeTokenCachedBalanceByChainIdByAccountAddress( - state, - fromAddress ?? '', - ), - ) as Record; - - const balance = chainBalances?.[chainId as Hex] ?? '0x0'; - - const totalValue = sumHexes(value, ...batchTransactionValues); - - const { hexMaximumTransactionFee } = useSelector((state) => - selectTransactionFeeById(state, transactionId), - ); + const isGasFeeTokensEmpty = gasFeeTokens?.length === 0; - const [multichainNetworks, evmNetworks] = useSelector( - getMultichainNetworkConfigurationsByChainId, - ); + // Check if gasless check has completed (regardless of result) + const isGaslessCheckComplete = !isGaslessSupportedPending; - const nativeCurrency = ( - multichainNetworks[chainId as CaipChainId] ?? evmNetworks[chainId] - )?.nativeCurrency; + // Transaction is sponsored only if it's marked as sponsored AND gasless is supported + const isSponsoredTransaction = isSponsored && isGaslessSupported; - const insufficientBalance = !isBalanceSufficient({ - amount: totalValue, - gasTotal: hexMaximumTransactionFee, - balance, - }); + // Simulation is complete if it's disabled, or if enabled and gasFeeTokens is loaded + const isSimulationComplete = !isSimulationEnabled || Boolean(gasFeeTokens); - const isSponsored = currentConfirmation?.isGasFeeSponsored; - const { isSupported: isGaslessSupported } = useIsGaslessSupported(); - const isSponsoredTransaction = isSponsored && isGaslessSupported; + // Check if user has selected a gas fee token (or we're ignoring that check) + const hasNoGasFeeTokenSelected = ignoreGasFeeToken || !selectedGasFeeToken; - const canSkipSimulationChecks = ignoreGasFeeToken || !isSimulationEnabled; - const hasGaslessSimulationFinished = - canSkipSimulationChecks || Boolean(gasFeeTokens); + // Show alert when gasless check is done and either: + // - Gasless is NOT supported (user needs native currency for gas) + // - Gasless IS supported but gasFeeTokens is empty (no alternative tokens available) + const shouldCheckGaslessConditions = + isGaslessCheckComplete && (!isGaslessSupported || isGasFeeTokensEmpty); const showAlert = - insufficientBalance && - hasGaslessSimulationFinished && - (ignoreGasFeeToken || !selectedGasFeeToken) && + hasInsufficientBalance && + isSimulationComplete && + hasNoGasFeeTokenSelected && + shouldCheckGaslessConditions && !isSponsoredTransaction; return useMemo(() => { diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts index 9ec35e20e78b..f4d29d051e03 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts @@ -2,15 +2,15 @@ import { GasFeeToken } from '@metamask/transaction-controller'; import { renderHookWithConfirmContextProvider } from '../../../../../test/lib/confirmations/render-helpers'; import { genUnapprovedContractInteractionConfirmation } from '../../../../../test/data/confirmations/contract-interaction'; import { getMockConfirmStateForTransaction } from '../../../../../test/data/confirmations/helper'; -import { useIsInsufficientBalance } from '../useIsInsufficientBalance'; +import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { useIsGaslessLoading } from './useIsGaslessLoading'; import { useIsGaslessSupported } from './useIsGaslessSupported'; jest.mock('./useIsGaslessSupported'); -jest.mock('../useIsInsufficientBalance'); +jest.mock('../useHasInsufficientBalance'); const mockedUseIsGaslessSupported = jest.mocked(useIsGaslessSupported); -const mockedUseIsInsufficientBalance = jest.mocked(useIsInsufficientBalance); +const mockedUseHasInsufficientBalance = jest.mocked(useHasInsufficientBalance); async function runHook({ simulationEnabled, @@ -26,8 +26,12 @@ async function runHook({ mockedUseIsGaslessSupported.mockReturnValue({ isSupported: gaslessSupported, isSmartTransaction: true, + pending: false, + }); + mockedUseHasInsufficientBalance.mockReturnValue({ + hasInsufficientBalance: insufficientBalance, + nativeCurrency: 'USD', }); - mockedUseIsInsufficientBalance.mockReturnValue(insufficientBalance); const { result } = renderHookWithConfirmContextProvider( useIsGaslessLoading, diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts index 8b5a0296cd91..be91cc1d7a66 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts @@ -2,7 +2,7 @@ import { useSelector } from 'react-redux'; import { TransactionMeta } from '@metamask/transaction-controller'; import { useConfirmContext } from '../../context/confirm'; import { getUseTransactionSimulations } from '../../../../selectors'; -import { useIsInsufficientBalance } from '../useIsInsufficientBalance'; +import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { useIsGaslessSupported } from './useIsGaslessSupported'; export function useIsGaslessLoading() { @@ -11,15 +11,17 @@ export function useIsGaslessLoading() { const { gasFeeTokens } = transactionMeta ?? {}; - const { isSupported: isGaslessSupported } = useIsGaslessSupported(); + const { isSupported: isGaslessSupported, pending } = useIsGaslessSupported(); const isSimulationEnabled = useSelector(getUseTransactionSimulations); - const hasInsufficientNative = useIsInsufficientBalance(); + const { hasInsufficientBalance } = useHasInsufficientBalance(); + + const isGaslessSupportedFinished = !pending && isGaslessSupported; const isGaslessLoading = isSimulationEnabled && - isGaslessSupported && - hasInsufficientNative && + isGaslessSupportedFinished && + hasInsufficientBalance && !gasFeeTokens; return { isGaslessLoading }; diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.test.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.test.ts index 7efed5971370..8b0fae932b6e 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.test.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.test.ts @@ -62,6 +62,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: true, isSmartTransaction: true, + pending: false, }); }); @@ -74,6 +75,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: true, isSmartTransaction: false, + pending: false, }); }); @@ -85,6 +87,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }); }); @@ -96,6 +99,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }); }); @@ -107,6 +111,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: false, isSmartTransaction: false, + pending: false, }); }); }); @@ -121,6 +126,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: false, isSmartTransaction: true, + pending: false, }); }); @@ -142,6 +148,7 @@ describe('useIsGaslessSupported', () => { expect(result).toStrictEqual({ isSupported: false, isSmartTransaction: true, + pending: true, }); }); }); diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.ts index 2c8cccceb37e..7b4e07cc22d3 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessSupported.ts @@ -14,6 +14,7 @@ import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmart * @returns An object containing: * - `isSupported`: `true` if gasless transactions are supported via either 7702 or smart transactions with sendBundle. * - `isSmartTransaction`: `true` if smart transactions are enabled for the current chain. + * - `pending`: `true` if the support check is still in progress. */ export function useIsGaslessSupported() { const { currentConfirmation: transactionMeta } = @@ -24,19 +25,19 @@ export function useIsGaslessSupported() { const { isSmartTransaction, isSupported: isSmartTransactionAndBundleSupported, - pending, + pending: smartTransactionPending, } = useGaslessSupportedSmartTransactions(); const shouldCheck7702Eligibility = - !pending && !isSmartTransactionAndBundleSupported; + !smartTransactionPending && !isSmartTransactionAndBundleSupported; + const { value: relaySupportsChain, pending: relayPending } = + useAsyncResult(async () => { + if (!shouldCheck7702Eligibility) { + return undefined; + } - const { value: relaySupportsChain } = useAsyncResult(async () => { - if (!shouldCheck7702Eligibility) { - return undefined; - } - - return isRelaySupported(chainId); - }, [chainId, shouldCheck7702Eligibility]); + return isRelaySupported(chainId); + }, [chainId, shouldCheck7702Eligibility]); const is7702Supported = Boolean( relaySupportsChain && @@ -48,8 +49,12 @@ export function useIsGaslessSupported() { isSmartTransactionAndBundleSupported || is7702Supported, ); + const isPending = + smartTransactionPending || (shouldCheck7702Eligibility && relayPending); + return { isSupported, isSmartTransaction, + pending: isPending, }; } diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 8f4dc8ade413..c4eadf9914fb 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -85,6 +85,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: false, + pending: false, }); updateAndApproveTxMock.mockReturnValue(() => Promise.resolve({} as TransactionMeta), @@ -93,6 +94,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: false, isSmartTransaction: false, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ @@ -132,6 +134,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ isSupported: true, @@ -168,6 +171,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ isSupported: true, @@ -197,6 +201,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); const { onTransactionConfirm } = runHook({ @@ -221,6 +226,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ isSupported: true, @@ -256,6 +262,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: true, isSupported: true, + pending: false, }); useGaslessSupportedSmartTransactionsMock.mockReturnValue({ isSupported: false, @@ -285,6 +292,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSmartTransaction: false, isSupported: true, + pending: false, }); const { onTransactionConfirm } = runHook({ @@ -424,6 +432,7 @@ describe('useTransactionConfirm', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, }); const { onTransactionConfirm } = runHook({ diff --git a/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts b/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts index a0f9851baaf6..80766de5cdbc 100644 --- a/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts +++ b/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.test.ts @@ -8,15 +8,14 @@ import { getMockConfirmStateForTransaction } from '../../../../test/data/confirm import { renderHookWithConfirmContextProvider } from '../../../../test/lib/confirmations/render-helpers'; import { flushPromises } from '../../../../test/lib/timer-helpers'; import { updateSelectedGasFeeToken } from '../../../store/controller-actions/transaction-controller'; -import { Alert } from '../../../ducks/confirm-alerts/confirm-alerts'; import { forceUpdateMetamaskState } from '../../../store/actions'; import { GAS_FEE_TOKEN_MOCK } from '../../../../test/data/confirmations/gas'; -import { useInsufficientBalanceAlerts } from './alerts/transactions/useInsufficientBalanceAlerts'; import { useAutomaticGasFeeTokenSelect } from './useAutomaticGasFeeTokenSelect'; import { useIsGaslessSupported } from './gas/useIsGaslessSupported'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; jest.mock('../../../store/controller-actions/transaction-controller'); -jest.mock('./alerts/transactions/useInsufficientBalanceAlerts'); +jest.mock('./useHasInsufficientBalance'); jest.mock('../../../../shared/modules/selectors'); jest.mock('./gas/useIsGaslessSupported'); @@ -58,19 +57,21 @@ describe('useAutomaticGasFeeTokenSelect', () => { const forceUpdateMetamaskStateMock = jest.mocked(forceUpdateMetamaskState); const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); - const useInsufficientBalanceAlertsMock = jest.mocked( - useInsufficientBalanceAlerts, - ); + const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance); beforeEach(() => { jest.resetAllMocks(); - useInsufficientBalanceAlertsMock.mockReturnValue([{} as Alert]); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: true, + nativeCurrency: 'ETH', + }); updateSelectedGasFeeTokenMock.mockResolvedValue(); forceUpdateMetamaskStateMock.mockResolvedValue(); useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: true, + pending: false, }); }); @@ -148,6 +149,7 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: false, isSmartTransaction: false, + pending: false, }); runHook(); @@ -159,7 +161,10 @@ describe('useAutomaticGasFeeTokenSelect', () => { }); it('does not select first gas fee token if sufficient balance', async () => { - useInsufficientBalanceAlertsMock.mockReturnValue([]); + useHasInsufficientBalanceMock.mockReturnValue({ + hasInsufficientBalance: false, + nativeCurrency: 'ETH', + }); runHook(); @@ -170,8 +175,11 @@ describe('useAutomaticGasFeeTokenSelect', () => { }); it('selects first gas fee token when insufficient balance appears after first render', async () => { - let alerts: Alert[] = []; - useInsufficientBalanceAlertsMock.mockImplementation(() => alerts); + let balanceInfo = { + hasInsufficientBalance: false, + nativeCurrency: 'ETH', + }; + useHasInsufficientBalanceMock.mockImplementation(() => balanceInfo); const { rerender, store } = runHook({ selectedGasFeeToken: undefined, @@ -186,7 +194,10 @@ describe('useAutomaticGasFeeTokenSelect', () => { expect(updateSelectedGasFeeTokenMock).toHaveBeenCalledTimes(0); expect(forceUpdateMetamaskStateMock).toHaveBeenCalledTimes(0); - alerts = [{} as Alert]; + balanceInfo = { + hasInsufficientBalance: true, + nativeCurrency: 'ETH', + }; rerender(); @@ -256,6 +267,7 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, }); runHook({ @@ -277,6 +289,7 @@ describe('useAutomaticGasFeeTokenSelect', () => { useIsGaslessSupportedMock.mockReturnValue({ isSupported: true, isSmartTransaction: false, + pending: false, }); const { store } = runHook({ diff --git a/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts b/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts index 6f0904133969..176a20232843 100644 --- a/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts +++ b/ui/pages/confirmations/hooks/useAutomaticGasFeeTokenSelect.ts @@ -7,21 +7,22 @@ import { useAsyncResult } from '../../../hooks/useAsync'; import { forceUpdateMetamaskState } from '../../../store/actions'; import { updateSelectedGasFeeToken } from '../../../store/controller-actions/transaction-controller'; import { useConfirmContext } from '../context/confirm'; -import { useInsufficientBalanceAlerts } from './alerts/transactions/useInsufficientBalanceAlerts'; import { useIsGaslessSupported } from './gas/useIsGaslessSupported'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; export function useAutomaticGasFeeTokenSelect() { const dispatch = useDispatch(); - const { isSupported: isGaslessSupported, isSmartTransaction } = - useIsGaslessSupported(); + const { + isSupported: isGaslessSupported, + isSmartTransaction, + pending, + } = useIsGaslessSupported(); const [firstCheck, setFirstCheck] = useState(true); const { currentConfirmation: transactionMeta } = useConfirmContext(); - const hasInsufficientBalance = Boolean( - useInsufficientBalanceAlerts()?.length, - ); + const { hasInsufficientBalance } = useHasInsufficientBalance(); const { gasFeeTokens, @@ -40,8 +41,10 @@ export function useAutomaticGasFeeTokenSelect() { await forceUpdateMetamaskState(dispatch); }, [dispatch, transactionId, firstGasFeeTokenAddress]); + const isGaslessSupportedAndFinished = isGaslessSupported && !pending; + const shouldSelect = - isGaslessSupported && + isGaslessSupportedAndFinished && hasInsufficientBalance && !selectedGasFeeToken && Boolean(firstGasFeeTokenAddress); diff --git a/ui/pages/confirmations/hooks/useHasInsufficientBalance.test.ts b/ui/pages/confirmations/hooks/useHasInsufficientBalance.test.ts new file mode 100644 index 000000000000..a5a9da172d9c --- /dev/null +++ b/ui/pages/confirmations/hooks/useHasInsufficientBalance.test.ts @@ -0,0 +1,120 @@ +import { + TransactionMeta, + TransactionParams, + TransactionType, +} from '@metamask/transaction-controller'; +import { ApprovalType, toHex } from '@metamask/controller-utils'; +import { renderHookWithConfirmContextProvider } from '../../../../test/lib/confirmations/render-helpers'; +import { getMockConfirmState } from '../../../../test/data/confirmations/helper'; +import { genUnapprovedContractInteractionConfirmation } from '../../../../test/data/confirmations/contract-interaction'; +import { useHasInsufficientBalance } from './useHasInsufficientBalance'; + +const TRANSACTION_ID_MOCK = '123-456'; +const TRANSACTION_MOCK = { + ...genUnapprovedContractInteractionConfirmation({ + chainId: '0x5', + }), + id: TRANSACTION_ID_MOCK, + txParams: { + from: '0x123', + value: '0x2', + maxFeePerGas: '0x2', + gas: '0x3', + } as TransactionParams, +} as TransactionMeta; + +function buildState({ + balance, + currentConfirmation = TRANSACTION_MOCK, + transaction = TRANSACTION_MOCK, + selectedNetworkClientId, + chainId, +}: { + balance?: number; + currentConfirmation?: Partial; + transaction?: Partial; + selectedNetworkClientId?: string; + chainId?: string; +} = {}) { + const accountAddress = transaction?.txParams?.from as string; + + let pendingApprovals = {}; + if (currentConfirmation) { + pendingApprovals = { + [currentConfirmation.id as string]: { + id: currentConfirmation.id, + type: ApprovalType.Transaction, + }, + }; + } + + return getMockConfirmState({ + metamask: { + selectedNetworkClientId: selectedNetworkClientId ?? 'goerli', + pendingApprovals, + accountsByChainId: { + [chainId ?? '0x5']: { + [accountAddress]: { balance: toHex(balance ?? 0) }, + }, + }, + transactions: transaction ? [transaction] : [], + }, + }); +} + +function runHook(stateOptions?: Parameters[0]) { + const state = buildState(stateOptions); + const response = renderHookWithConfirmContextProvider( + () => useHasInsufficientBalance(), + state, + ); + + return response.result.current; +} + +describe('useHasInsufficientBalance', () => { + it('returns false if balance sufficient for value + fee', () => { + const result = runHook({ balance: 900000000000 }); + expect(result.hasInsufficientBalance).toBe(false); + expect(result.nativeCurrency).toBe('ETH'); + }); + + it('returns true if balance insufficient for value + fee', () => { + const result = runHook({ balance: 0 }); + expect(result.hasInsufficientBalance).toBe(true); + }); + + it('sums nested transaction values correctly', () => { + const BATCH_TRANSACTION_MOCK = { + ...TRANSACTION_MOCK, + nestedTransactions: [ + { + to: '0x1234567890123456789012345678901234567890', + value: '0x3B9ACA00', + type: TransactionType.simpleSend, + }, + { + to: '0x1234567890123456789012345678901234567891', + value: '0x1DCD6500', + type: TransactionType.simpleSend, + }, + ], + }; + const result = runHook({ + currentConfirmation: BATCH_TRANSACTION_MOCK as Partial, + transaction: BATCH_TRANSACTION_MOCK as Partial, + balance: 0x10, + }); + expect(result.hasInsufficientBalance).toBe(true); + }); + + it('returns nativeCurrency from evmNetworks if multichain missing', () => { + const result = runHook(); + expect(result.nativeCurrency).toBe('ETH'); + }); + + it('returns 0x0 if balance missing', () => { + const result = runHook({ balance: undefined }); + expect(result.hasInsufficientBalance).toBe(true); + }); +}); diff --git a/ui/pages/confirmations/hooks/useHasInsufficientBalance.ts b/ui/pages/confirmations/hooks/useHasInsufficientBalance.ts new file mode 100644 index 000000000000..8a5b874a3e7e --- /dev/null +++ b/ui/pages/confirmations/hooks/useHasInsufficientBalance.ts @@ -0,0 +1,60 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { CaipChainId, Hex } from '@metamask/utils'; +import { useSelector } from 'react-redux'; + +import { sumHexes } from '../../../../shared/modules/conversion.utils'; +import { + getMultichainNetworkConfigurationsByChainId, + getNativeTokenCachedBalanceByChainIdByAccountAddress, + selectTransactionFeeById, +} from '../../../selectors'; +import { useConfirmContext } from '../context/confirm'; +import { isBalanceSufficient } from '../send-legacy/send.utils'; + +export function useHasInsufficientBalance(): { + hasInsufficientBalance: boolean; + nativeCurrency?: string; +} { + const { currentConfirmation } = useConfirmContext(); + const { + id: transactionId, + chainId, + txParams: { value = '0x0', from: fromAddress = '' } = {}, + } = currentConfirmation ?? {}; + + const batchTransactionValues = + currentConfirmation?.nestedTransactions?.map( + (trxn) => (trxn.value as Hex) ?? 0x0, + ) ?? []; + + const chainBalances = useSelector((state) => + getNativeTokenCachedBalanceByChainIdByAccountAddress( + state, + fromAddress ?? '', + ), + ) as Record; + + const balance = chainBalances?.[chainId as Hex] ?? '0x0'; + + const totalValue = sumHexes(value, ...batchTransactionValues); + + const { hexMaximumTransactionFee } = useSelector((state) => + selectTransactionFeeById(state, transactionId), + ); + + const [multichainNetworks, evmNetworks] = useSelector( + getMultichainNetworkConfigurationsByChainId, + ); + + const nativeCurrency = ( + multichainNetworks[chainId as CaipChainId] ?? evmNetworks[chainId] + )?.nativeCurrency; + + const insufficientBalance = !isBalanceSufficient({ + amount: totalValue, + gasTotal: hexMaximumTransactionFee, + balance, + }); + + return { hasInsufficientBalance: insufficientBalance, nativeCurrency }; +}