Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ describe('ConfirmFooter', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: false,
pending: false,
});

useIsGaslessLoadingMock.mockReturnValue({
Expand Down Expand Up @@ -216,6 +217,7 @@ describe('ConfirmFooter', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: true,
isSupported: true,
pending: false,
});
useInsufficientBalanceAlertsMock.mockReturnValue(ALERT_MOCK);
jest.spyOn(confirmContext, 'useConfirmContext').mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe('GasFeeTokenModal', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: true,
isSupported: true,
pending: false,
});
});

Expand Down Expand Up @@ -212,6 +213,7 @@ describe('GasFeeTokenModal', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: true,
pending: false,
});

const result = renderWithConfirmContextProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ import { genUnapprovedContractInteractionConfirmation } from '../../../../../../
import { renderWithConfirmContextProvider } from '../../../../../../../../test/lib/confirmations/render-helpers';
import { GAS_FEE_TOKEN_MOCK } from '../../../../../../../../test/data/confirmations/gas';
import { useIsGaslessSupported } from '../../../../../hooks/gas/useIsGaslessSupported';
import { useInsufficientBalanceAlerts } from '../../../../../hooks/alerts/transactions/useInsufficientBalanceAlerts';
import { Severity } from '../../../../../../../helpers/constants/design-system';
import * as DappSwapContext from '../../../../../context/dapp-swap';
import { useHasInsufficientBalance } from '../../../../../hooks/useHasInsufficientBalance';
import { SelectedGasFeeToken } from './selected-gas-fee-token';

jest.mock('../../../../../../../../shared/modules/selectors');
jest.mock('../../../../../hooks/gas/useIsGaslessSupported');
jest.mock(
'../../../../../hooks/alerts/transactions/useInsufficientBalanceAlerts',
);
jest.mock('../../../../../hooks/useHasInsufficientBalance');

function getStore({
gasFeeTokens,
Expand Down Expand Up @@ -47,25 +44,21 @@ function getStore({

describe('SelectedGasFeeToken', () => {
const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported);
const useInsufficientBalanceAlertsMock = jest.mocked(
useInsufficientBalanceAlerts,
);
const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance);

beforeEach(() => {
jest.resetAllMocks();

useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: true,
isSupported: true,
pending: false,
});

useInsufficientBalanceAlertsMock.mockReturnValue([
{
content: 'Insufficient balance',
key: 'insufficientBalance',
severity: Severity.Danger,
},
]);
useHasInsufficientBalanceMock.mockReturnValue({
hasInsufficientBalance: true,
nativeCurrency: 'ETH',
});
});

it('renders native symbol', () => {
Expand Down Expand Up @@ -110,6 +103,7 @@ describe('SelectedGasFeeToken', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: false,
pending: false,
});

const result = renderWithConfirmContextProvider(
Expand All @@ -124,6 +118,7 @@ describe('SelectedGasFeeToken', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: true,
pending: false,
});

const result = renderWithConfirmContextProvider(
Expand All @@ -139,7 +134,10 @@ describe('SelectedGasFeeToken', () => {
});

it('does not render arrow icon if sufficient balance and future native only', () => {
useInsufficientBalanceAlertsMock.mockReturnValue([]);
useHasInsufficientBalanceMock.mockReturnValue({
hasInsufficientBalance: false,
nativeCurrency: 'ETH',
});

const result = renderWithConfirmContextProvider(
<SelectedGasFeeToken />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { GasFeeTokenModal } from '../gas-fee-token-modal';
import { useSelectedGasFeeToken } from '../../hooks/useGasFeeToken';
import { GasFeeTokenIcon, GasFeeTokenIconSize } from '../gas-fee-token-icon';
import { useIsGaslessSupported } from '../../../../../hooks/gas/useIsGaslessSupported';
import { useIsInsufficientBalance } from '../../../../../hooks/useIsInsufficientBalance';
import { useHasInsufficientBalance } from '../../../../../hooks/useHasInsufficientBalance';

// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand All @@ -36,13 +36,13 @@ export function SelectedGasFeeToken() {
const { isSupported: isGaslessSupported, isSmartTransaction } =
useIsGaslessSupported();

const hasInsufficientNative = useIsInsufficientBalance();
const { hasInsufficientBalance } = useHasInsufficientBalance();

const hasOnlyFutureNativeToken =
gasFeeTokens?.length === 1 &&
gasFeeTokens[0].tokenAddress === NATIVE_TOKEN_ADDRESS;

const supportsFutureNative = hasInsufficientNative && isSmartTransaction;
const supportsFutureNative = hasInsufficientBalance && isSmartTransaction;

const hasGasFeeTokens =
!isQuotedSwapDisplayedInInfo &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe('useInsufficientBalanceAlerts', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: false,
pending: false,
});
});

Expand All @@ -117,6 +118,7 @@ describe('useInsufficientBalanceAlerts', () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: true,
pending: false,
});

const alerts = runHook({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
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,
} from '../../../../../components/app/confirm/info/row/constants';
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,
Expand All @@ -28,62 +20,41 @@ export function useInsufficientBalanceAlerts({
} = {}): Alert[] {
const t = useI18nContext();
const { currentConfirmation } = useConfirmContext<TransactionMeta>();
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<Hex, Hex>;

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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Alert suppressed when simulation disabled but gasless supported

When simulation is disabled and gasless IS supported, the insufficient balance alert will never show. The old code had canSkipSimulationChecks = ignoreGasFeeToken || !isSimulationEnabled which allowed the alert to show when simulation was disabled. The new shouldCheckGaslessConditions requires !isGaslessSupported || isGasFeeTokensEmpty, but when gasFeeTokens is undefined (never loaded because simulation is disabled), isGasFeeTokensEmpty is false (since undefined?.length === 0 evaluates to false). This means users with insufficient balance who have simulation disabled but gasless supported will never see the alert, even though they cannot actually use gas fee tokens.

Additional Locations (1)

Fix in Cursor Fix in Web


const showAlert =
insufficientBalance &&
hasGaslessSimulationFinished &&
(ignoreGasFeeToken || !selectedGasFeeToken) &&
hasInsufficientBalance &&
isSimulationComplete &&
hasNoGasFeeTokenSelected &&
shouldCheckGaslessConditions &&
!isSponsoredTransaction;

return useMemo(() => {
Expand Down
12 changes: 8 additions & 4 deletions ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 7 additions & 5 deletions ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: true,
isSmartTransaction: true,
pending: false,
});
});

Expand All @@ -74,6 +75,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: true,
isSmartTransaction: false,
pending: false,
});
});

Expand All @@ -85,6 +87,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: false,
pending: false,
});
});

Expand All @@ -96,6 +99,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: false,
pending: false,
});
});

Expand All @@ -107,6 +111,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: false,
pending: false,
});
});
});
Expand All @@ -121,6 +126,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: true,
pending: false,
});
});

Expand All @@ -142,6 +148,7 @@ describe('useIsGaslessSupported', () => {
expect(result).toStrictEqual({
isSupported: false,
isSmartTransaction: true,
pending: true,
});
});
});
Loading
Loading