diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts index 03feefb8c7b5..fd8400e8b6fe 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts @@ -15,6 +15,37 @@ jest.mock('../../../../../util/transaction-controller'); jest.mock('../../../../../util/transactions/transaction-relay'); jest.mock('../transactions/useTransactionMetadataRequest'); +const SMART_TRANSACTIONS_ENABLED_STATE = { + swaps: { + featureFlags: { + smart_transactions: { + mobile_active: true, + extension_active: true, + }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + }, + }, + '0x1': { + isLive: true, + featureFlags: { + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 160, + mobileReturnTxHashAsap: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + }, + }, + }, + }, +}; + describe('useIsGaslessSupported', () => { const mockUseTransactionMetadataRequest = jest.mocked( useTransactionMetadataRequest, @@ -33,46 +64,19 @@ describe('useIsGaslessSupported', () => { isSendBundleSupportedMock.mockResolvedValue(false); }); - describe('when gasless supported', () => { + describe('Gasless Smart Transactions', () => { it('returns isSupported and isSmartTransaction as true', async () => { const stateWithSmartTransactionEnabled = merge( {}, transferConfirmationState, - { - swaps: { - featureFlags: { - smart_transactions: { - mobile_active: true, - extension_active: true, - }, - smartTransactions: { - mobileActive: true, - extensionActive: true, - mobileActiveIOS: true, - mobileActiveAndroid: true, - }, - }, - '0x1': { - isLive: true, - featureFlags: { - smartTransactions: { - expectedDeadline: 45, - maxDeadline: 160, - mobileReturnTxHashAsap: false, - mobileActive: true, - extensionActive: true, - mobileActiveIOS: true, - mobileActiveAndroid: true, - }, - }, - }, - }, - }, + SMART_TRANSACTIONS_ENABLED_STATE, ); isSendBundleSupportedMock.mockResolvedValue(true); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { state: stateWithSmartTransactionEnabled, }); + await waitFor(() => expect(result.current).toEqual({ isSupported: true, @@ -80,204 +84,243 @@ describe('useIsGaslessSupported', () => { }), ); }); - }); - it('returns isSupported and isSmartTransaction as false when smart transactions are disabled', async () => { - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state: transferTransactionStateMock, - }); - await waitFor(() => - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, - }), - ); - }); + it('returns isSupported and isSmartTransaction as false when smart transactions are disabled', async () => { + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state: transferTransactionStateMock, + }); - it('returns isSupported and isSmartTransaction as false when chainId is undefined', async () => { - mockUseTransactionMetadataRequest.mockReturnValue(undefined); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state: transferTransactionStateMock, + await waitFor(() => + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }), + ); }); - await waitFor(() => - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, - }), - ); - }); - it('returns isSupported and isSmartTransaction as false when transactionMeta is null', async () => { - mockUseTransactionMetadataRequest.mockReturnValue(undefined); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state: transferTransactionStateMock, - }); - await waitFor(() => - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, - }), - ); - }); + it('returns false if smart transaction is enabled but sendBundle is not supported', async () => { + isSendBundleSupportedMock.mockResolvedValue(false); - it('returns isSupported true and isSmartTransaction: false when EIP-7702 conditions met', async () => { - isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { - chainId: '0x1', - isSupported: true, - delegationAddress: '0xde1', - }, - ]); + const stateWithSmartTransactionEnabled = merge( + {}, + transferConfirmationState, + SMART_TRANSACTIONS_ENABLED_STATE, + ); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, - }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: true, - isSmartTransaction: false, + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state: stateWithSmartTransactionEnabled, }); + + await waitFor(() => + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: true, + }), + ); }); }); - it('returns isSupported false and isSmartTransaction: false when atomicBatchSupported account not upgraded', async () => { - isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { - chainId: '0x1', - isSupported: false, - delegationAddress: undefined, - }, - ]); + describe('Gasless EIP-7702', () => { + it('returns isSupported true and isSmartTransaction: false when EIP-7702 conditions met', async () => { + isRelaySupportedMock.mockResolvedValue(true); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: true, + delegationAddress: '0xde1', + }, + ]); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, - }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: true, + isSmartTransaction: false, + }); }); }); - }); - it('returns isSupported false and isSmartTransaction: false when relay not supported', async () => { - isRelaySupportedMock.mockResolvedValue(false); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { - chainId: '0x1', - isSupported: true, - delegationAddress: '0xde1', - }, - ]); + it('returns isSupported false and isSmartTransaction: false when atomicBatchSupported account not upgraded', async () => { + isRelaySupportedMock.mockResolvedValue(true); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: false, + delegationAddress: undefined, + }, + ]); + + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); + }); }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + + it('returns isSupported false and isSmartTransaction: false when relay not supported', async () => { + isRelaySupportedMock.mockResolvedValue(false); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: true, + delegationAddress: '0xde1', + }, + ]); + + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); }); }); - }); - it('returns isSupported false if the transaction is contract deployment (no "to" param)', async () => { - mockUseTransactionMetadataRequest.mockReturnValue({ - chainId: '0x1', - txParams: { from: '0x123' }, // no "to" - } as unknown as TransactionMeta); - isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { + it('returns isSupported false if the transaction is contract deployment (no "to" param)', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ chainId: '0x1', - isSupported: true, - delegationAddress: '0xde1', - }, - ]); + txParams: { from: '0x123' }, // no "to" + } as unknown as TransactionMeta); + isRelaySupportedMock.mockResolvedValue(true); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: true, + delegationAddress: '0xde1', + }, + ]); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, - }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); }); }); - }); - it('returns isSupported false and isSmartTransaction: false when no matching chain support in atomicBatch', async () => { - isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { - chainId: '0x3', - isSupported: true, - delegationAddress: '0xde1', - }, - ]); + it('returns isSupported false and isSmartTransaction: false when no matching chain support in atomicBatch', async () => { + isRelaySupportedMock.mockResolvedValue(true); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x3', + isSupported: true, + delegationAddress: '0xde1', + }, + ]); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, - }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); }); }); - }); - it('returns isSupported false and isSmartTransaction: false if isAtomicBatchSupported returns undefined', async () => { - isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue( - undefined as unknown as ReturnType, - ); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, + it('returns isSupported false and isSmartTransaction: false if isAtomicBatchSupported returns undefined', async () => { + isRelaySupportedMock.mockResolvedValue(true); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue( + undefined as unknown as ReturnType, + ); + + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); + }); }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + + it('returns isSupported false and isSmartTransaction: false if isRelaySupported returns undefined', async () => { + isRelaySupportedMock.mockResolvedValue( + undefined as unknown as ReturnType, + ); + isSendBundleSupportedMock.mockResolvedValue(false); + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: true, + delegationAddress: '0xde1', + }, + ]); + + const state = merge({}, transferTransactionStateMock); + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }); }); }); }); - it('returns isSupported false and isSmartTransaction: false if isRelaySupported returns undefined', async () => { - isRelaySupportedMock.mockResolvedValue( - undefined as unknown as ReturnType, - ); - isSendBundleSupportedMock.mockResolvedValue(false); - isAtomicBatchSupportedMock.mockResolvedValue([ - { - chainId: '0x1', - isSupported: true, - delegationAddress: '0xde1', - }, - ]); - const state = merge({}, transferTransactionStateMock); - const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { - state, + describe('Edge Cases', () => { + it('returns isSupported and isSmartTransaction as false when chainId is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state: transferTransactionStateMock, + }); + + await waitFor(() => + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }), + ); }); - await waitFor(() => { - expect(result.current).toEqual({ - isSupported: false, - isSmartTransaction: false, + + it('returns isSupported and isSmartTransaction as false when transactionMeta is null', async () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { + state: transferTransactionStateMock, }); + + await waitFor(() => + expect(result.current).toEqual({ + isSupported: false, + isSmartTransaction: false, + }), + ); }); }); }); diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts index 89b7fbd82882..e3ce76a46e1b 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts @@ -1,6 +1,6 @@ import { useSelector } from 'react-redux'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; -import { selectSmartTransactionsEnabled } from '../../../../../selectors/smartTransactionsController'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; import { RootState } from '../../../../../reducers'; import { useAsyncResult } from '../../../../hooks/useAsyncResult'; import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; @@ -8,6 +8,17 @@ import { isRelaySupported } from '../../../../../util/transactions/transaction-r import { isAtomicBatchSupported } from '../../../../../util/transaction-controller'; import { Hex } from '@metamask/utils'; +/** + * Hook to determine if gasless transactions are supported for the current confirmation context. + * + * Gasless support can be enabled in two ways: + * - Via 7702: Supported when the current account is upgraded, the chain supports atomic batch, relay is available, and the transaction is not a contract deployment. + * - Via Smart Transactions: Supported when smart transactions are enabled and sendBundle is supported for the chain. + * + * @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. + */ export function useIsGaslessSupported() { const transactionMeta = useTransactionMetadataRequest(); @@ -15,11 +26,20 @@ export function useIsGaslessSupported() { const { from } = txParams ?? {}; const isSmartTransaction = useSelector((state: RootState) => - selectSmartTransactionsEnabled(state, chainId), + selectShouldUseSmartTransaction(state, chainId), + ); + + const { value: sendBundleSupportsChain } = useAsyncResult( + async () => (chainId ? isSendBundleSupported(chainId) : false), + [chainId], + ); + + const isSmartTransactionAndBundleSupported = Boolean( + isSmartTransaction && sendBundleSupportsChain, ); const { value: atomicBatchSupportResult } = useAsyncResult(async () => { - if (isSmartTransaction) { + if (isSmartTransactionAndBundleSupported) { return undefined; } @@ -27,20 +47,15 @@ export function useIsGaslessSupported() { address: from as Hex, chainIds: [chainId as Hex], }); - }, [chainId, from, isSmartTransaction]); + }, [chainId, from, isSmartTransactionAndBundleSupported]); const { value: relaySupportsChain } = useAsyncResult(async () => { - if (isSmartTransaction) { + if (isSmartTransactionAndBundleSupported) { return undefined; } return isRelaySupported(chainId as Hex); - }, [chainId, isSmartTransaction]); - - const { value: sendBundleSupportsChain } = useAsyncResult( - async () => (chainId ? isSendBundleSupported(chainId) : false), - [chainId], - ); + }, [chainId, isSmartTransactionAndBundleSupported]); const atomicBatchChainSupport = atomicBatchSupportResult?.find( (result) => result.chainId.toLowerCase() === chainId?.toLowerCase(), @@ -55,7 +70,7 @@ export function useIsGaslessSupported() { ); const isSupported = Boolean( - (isSmartTransaction && sendBundleSupportsChain) || is7702Supported, + isSmartTransactionAndBundleSupported || is7702Supported, ); return { diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 92d339a57d66..1cea96a0c542 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -591,4 +591,17 @@ describe('Transaction Controller Init', () => { expect(handler).toHaveBeenCalledWith(...expectedArgs); }); }); + + describe('option isEIP7702GasFeeTokensEnabled', () => { + it('returns false when feature flag is enabled for current chain', async () => { + const mockTransactionMeta = { + id: '123', + status: 'approved', + chainId: '0x1', + } as unknown as TransactionMeta; + const optionFn = testConstructorOption('isEIP7702GasFeeTokensEnabled'); + + expect(await optionFn?.(mockTransactionMeta)).toBe(false); + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index 353ba88ddd99..0dcd2de665a5 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -126,7 +126,18 @@ export const TransactionControllerInit: ControllerInitFunction< const { chainId } = transactionMeta; const state = getState(); - return !selectShouldUseSmartTransaction(state, chainId); + const isSmartTransactionEnabled = selectShouldUseSmartTransaction( + state, + chainId, + ); + const isSendBundleSupportedChain = await isSendBundleSupported( + chainId, + ); + + // EIP7702 gas fee tokens are enabled when: + // - Smart transactions are NOT enabled, OR + // - Send bundle is NOT supported + return !isSmartTransactionEnabled || !isSendBundleSupportedChain; }, isSimulationEnabled: () => preferencesController.state.useTransactionSimulations,