diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch new file mode 100644 index 00000000000..d841b6a6b85 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index b787174f2c8c448ed1ad9c8884204c5c8b6858be..af058623871badb4891564c003693ea19d0aa676 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -834,7 +834,13 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index 2fe71bdd2caf4d62f7946e9466b31367d360cd7c..fb9c0bc45abf88873452667b85ef2ea0cdfd929c 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -831,7 +831,13 @@ export class BridgeStatusController extends StaticIntervalPollingController() { + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch new file mode 100644 index 00000000000..d308173b6b2 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index ec19aeeecfa32a3cdf955ccc1152829ee4ddfd8f..d9b427f9f0f4b05238d79c731fc81566634a7c25 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -855,7 +855,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index a5661d63c35b5ad3526c1804936dc0e189c90c29..86efc019968599662466e643dae7002ebf5f5014 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -852,7 +852,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index ecf661c8351..eb9c0b82cfd 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -484,6 +484,50 @@ describe('useBridgeQuoteRequest', () => { }); }); + describe('hardware wallet accounts', () => { + it('sends gasIncluded and gasIncluded7702 false when useIsGasIncluded7702Supported dispatches false for hardware wallet', async () => { + // useIsGasIncluded7702Supported now incorporates the HW wallet check and + // dispatches isGasIncluded7702Supported=false for hardware wallets. + // useIsGasIncludedSTXSendBundleSupported already dispatches false for HW + // wallets via selectShouldUseSmartTransaction. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + isGasIncludedSTXSendBundleSupported: false, + isGasIncluded7702Supported: false, + sourceToken: { + address: '0xSourceToken', + chainId: '0x1', + decimals: 18, + symbol: 'SRC', + }, + destToken: { + address: '0xDestToken', + chainId: '0x1', + decimals: 18, + symbol: 'DEST', + }, + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(DEBOUNCE_WAIT); + }); + + expect(spyUpdateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + gasIncluded: false, + gasIncluded7702: false, + }), + undefined, + ); + }); + }); + describe('insufficientBal parameter', () => { it('includes insufficientBal false when balance is sufficient', async () => { mockUseIsInsufficientBalance.mockReturnValue(false); diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts index bafe1d3eac6..c3488f1cea1 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts @@ -8,13 +8,15 @@ import { formatChainIdToHex, isNonEvmChainId, } from '@metamask/bridge-controller'; +import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge'; /** * Hook that determines if 7702 gasless support is available for bridge/swap. * Should be used at the page level (e.g., BridgeView) to avoid repeated calculations. * - * Requirement for 7702: + * Requirements for 7702: * - Relay must be supported (for 7702 delegation) + * - Source wallet must not be a hardware wallet * * @param chainId - The chain ID to check (can be Hex, CAIP, or other format) - only EVM chains are supported */ @@ -40,9 +42,11 @@ export const useIsGasIncluded7702Supported = ( return isRelaySupported(evmChainId as Hex); }, [evmChainId]); + const isHardwareWallet = useIsHardwareWalletForBridge(); + // 7702 is available when ALL conditions are met const isGasIncluded7702Supported = Boolean( - evmChainId && !!isRelaySupportedForChain, + evmChainId && !!isRelaySupportedForChain && !isHardwareWallet, ); useEffect(() => { diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts index 7c31e0e6dd7..1a3d7c3a8de 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts @@ -7,8 +7,18 @@ import configureStore from '../../../../../util/test/configureStore'; // Mock dependencies jest.mock('../../../../../util/transactions/transaction-relay'); +jest.mock('../useIsHardwareWalletForBridge', () => ({ + useIsHardwareWalletForBridge: jest.fn().mockReturnValue(false), +})); const mockIsRelaySupported = jest.mocked(isRelaySupported); +const { useIsHardwareWalletForBridge } = jest.requireMock( + '../useIsHardwareWalletForBridge', +); +const mockUseIsHardwareWalletForBridge = + useIsHardwareWalletForBridge as jest.MockedFunction< + typeof useIsHardwareWalletForBridge + >; describe('useIsGasIncluded7702Supported', () => { const MAINNET_CHAIN_ID = '0x1' as Hex; @@ -28,6 +38,7 @@ describe('useIsGasIncluded7702Supported', () => { beforeEach(() => { jest.clearAllMocks(); mockIsRelaySupported.mockResolvedValue(false); + mockUseIsHardwareWalletForBridge.mockReturnValue(false); }); afterEach(() => { @@ -147,6 +158,32 @@ describe('useIsGasIncluded7702Supported', () => { }); }); + describe('when source wallet is a hardware account', () => { + it('updates isGasIncluded7702Supported to false even when relay is supported', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported(MAINNET_CHAIN_ID), + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + + it('updates isGasIncluded7702Supported to false for hardware wallet regardless of chain', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported('eip155:59144'), // Linea + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + }); + describe('edge cases', () => { it('handles case-insensitive chainId matching', async () => { mockIsRelaySupported.mockResolvedValue(true); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts new file mode 100644 index 00000000000..f1082ff5e73 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useIsHardwareWalletForBridge } from './index'; +import { isHardwareAccount } from '../../../../../util/address'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../util/address', () => ({ + isHardwareAccount: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('useIsHardwareWalletForBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(undefined); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('returns false when source wallet address is undefined', () => { + mockUseSelector.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).not.toHaveBeenCalled(); + }); + + it('returns true when source wallet is a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(true); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(true); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); + + it('returns false when source wallet is not a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(false); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts new file mode 100644 index 00000000000..c4ba9df5dee --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { isHardwareAccount } from '../../../../../util/address'; + +/** + * Returns whether the current bridge source account is a hardware wallet. + * Used to omit gas-included / 7702 params from bridge quote requests so responses + * are non-sponsored for hardware signers. + */ +export function useIsHardwareWalletForBridge(): boolean { + const walletAddress = useSelector(selectSourceWalletAddress); + + return useMemo( + () => Boolean(walletAddress && isHardwareAccount(walletAddress)), + [walletAddress], + ); +} diff --git a/app/components/UI/Bridge/utils/transaction.ts b/app/components/UI/Bridge/utils/transaction.ts index 77f981e8156..300a6fefb56 100644 --- a/app/components/UI/Bridge/utils/transaction.ts +++ b/app/components/UI/Bridge/utils/transaction.ts @@ -11,7 +11,9 @@ export const getIsBridgeTransaction = (txMeta: TransactionMeta) => { return ( origin === ORIGIN_METAMASK && (txMeta.type === TransactionType.bridgeApproval || - txMeta.type === TransactionType.bridge) + txMeta.type === TransactionType.bridge || + txMeta.type === TransactionType.swap || + txMeta.type === TransactionType.swapApproval) ); }; diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index 8d97ae24468..329ec5984d1 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -31,6 +31,27 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +// Avoid loading keyring-utils, keyring-api, and the network/Engine chain in this test +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountFormattedAddress: jest.fn(), +})); + +jest.mock('../../../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +jest.mock('@metamask/keyring-api', () => ({ + EntropySourceId: {}, + BtcMethod: {}, + EthMethod: {}, + SolAccountType: {}, + SolMethod: {}, + TrxMethod: {}, + isEvmAccountType: jest.fn(), + KeyringAccountType: {}, + EthScope: {}, +})); + jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(), })); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 9a3afef91ee..0ef4472b59f 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,6 +59,8 @@ import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import { strings } from '../../../../locales/i18n'; import TagColored, { TagColor, @@ -106,6 +108,12 @@ const NetworkMultiSelectList = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { styles } = useStyles(styleSheet, {}); @@ -269,7 +277,8 @@ const NetworkMultiSelectList = ({ const isDisabled = isLoading || isSelectionDisabled; const showButtonIcon = Boolean(networkTypeOrRpcUrl); - const isGasSponsored = isGasFeesSponsoredNetworkEnabled(chainId); + const isGasSponsored = + !isHardwareWallet && isGasFeesSponsoredNetworkEnabled(chainId); return ( @@ -342,6 +351,7 @@ const NetworkMultiSelectList = ({ isSelectAllNetworksSection, openRpcModal, isGasFeesSponsoredNetworkEnabled, + isHardwareWallet, styles.centeredNetworkCell, styles.noNetworkFeeContainer, ], @@ -351,11 +361,17 @@ const NetworkMultiSelectList = ({ if (!networks.length || !isAutoScrollEnabled) return; if (networksLengthRef.current !== networks.length) { const selectedNetwork = networks.find(({ isSelected }) => isSelected); - networkListRef?.current?.scrollToOffset({ - offset: selectedNetwork?.yOffset ?? 0, - animated: false, - }); + const offset = selectedNetwork?.yOffset ?? 0; networksLengthRef.current = networks.length; + // Defer scroll so FlashList has time to lay out items and avoid "index out of bounds" + requestAnimationFrame(() => { + if (networkListRef?.current?.scrollToOffset) { + networkListRef.current.scrollToOffset({ + offset, + animated: false, + }); + } + }); } }, [networks, isAutoScrollEnabled]); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index f1a34a6fed8..12351d22ead 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -107,6 +107,8 @@ import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/n import { analytics } from '../../../util/analytics/analytics'; import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import TagColored, { TagColor, } from '../../../component-library/components-temp/TagColored'; @@ -137,6 +139,12 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const networkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, @@ -559,7 +567,8 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { {name} - {isGasFeesSponsoredNetworkEnabled(chainId) ? ( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? ( { ) } tertiaryText={ - isSendFlow && isGasFeesSponsoredNetworkEnabled(chainId) + isSendFlow && + !isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? strings('networks.no_network_fee') : undefined } diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx index 16aca39e32b..1edd8ab10f8 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx @@ -34,6 +34,8 @@ import Icon, { } from '../../../../../../component-library/components/Icons/Icon'; import { selectAdditionalNetworksBlacklistFeatureFlag } from '../../../../../../selectors/featureFlagController/networkBlacklist'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../../util/address'; import TagColored, { TagColor, } from '../../../../../../component-library/components-temp/TagColored'; @@ -65,6 +67,12 @@ const CustomNetwork = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { safeChains } = useSafeChains(); const blacklistedChainIds = useSelector( selectAdditionalNetworksBlacklistFeatureFlag, @@ -181,7 +189,8 @@ const CustomNetwork = ({ {networkConfiguration.nickname} - {isGasFeesSponsoredNetworkEnabled( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled( networkConfiguration.chainId, ) ? ( { const handleTransactionAddedEventForMetricsMock = jest.mocked( handleTransactionAddedEventForMetrics, ); + const accountSupports7702Mock = jest.mocked(accountSupports7702); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); const selectMetaMaskPayFlagsMock = jest.mocked(selectMetaMaskPayFlags); const payHookClassMock = jest.mocked(TransactionPayPublishHook); @@ -422,6 +425,7 @@ describe('Transaction Controller Init', () => { let mockDelegation7702Hook: jest.MockedFn; beforeEach(() => { + accountSupports7702Mock.mockResolvedValue(true); payHookMock.mockResolvedValue({ transactionHash: undefined }); mockDelegation7702Hook = jest .fn() @@ -434,6 +438,20 @@ describe('Transaction Controller Init', () => { ); }); + it('skips Delegation7702PublishHook for hardware wallet accounts', async () => { + accountSupports7702Mock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + }); + it('falls back to Delegation7702PublishHook when smart transactions are disabled', async () => { selectShouldUseSmartTransactionMock.mockReturnValue(false); const hooks = testConstructorOption('hooks'); @@ -718,6 +736,7 @@ describe('Transaction Controller Init', () => { }); it('returns true if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const mockTransactionMeta = { id: '123', status: 'approved', @@ -732,6 +751,7 @@ describe('Transaction Controller Init', () => { }); it('calls getNonceLock and releaseLock via Delegation7702PublishHook getNextNonce', async () => { + accountSupports7702Mock.mockResolvedValue(true); const releaseLockMock = jest.fn(); const getNonceLockMock = jest.fn().mockResolvedValue({ nextNonce: 99, @@ -773,6 +793,7 @@ describe('Transaction Controller Init', () => { }); it('calls 7702 publish hook if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const delegation7702Mock: jest.MockedFn = jest.fn(); jest.mocked(Delegation7702PublishHook).mockImplementation( 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 b61e47c745c..a758e7066b4 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -52,6 +52,7 @@ import { } from '@metamask/transaction-pay-controller'; import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { trace } from '../../../../util/trace'; +import { accountSupports7702 } from '../../../../util/transactions/account-supports-7702'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { NetworkClientId } from '@metamask/network-controller'; @@ -110,6 +111,7 @@ export const TransactionControllerInit: ControllerInitFunction< publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -134,6 +136,15 @@ export const TransactionControllerInit: ControllerInitFunction< isFirstTimeInteractionEnabled: () => isFirstTimeInteractionEnabled(preferencesController), isEIP7702GasFeeTokensEnabled: async (transactionMeta) => { + if ( + !(await accountSupports7702( + transactionMeta.txParams?.from, + keyringController as Parameters[1], + )) + ) { + return false; + } + const { chainId, isExternalSign } = transactionMeta; const state = getState(); @@ -191,6 +202,7 @@ async function getNextNonce( async function publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -198,6 +210,7 @@ async function publishHook({ }: { transactionMeta: TransactionMeta; getState: () => RootState; + keyringController: Parameters[1]; transactionController: TransactionController; smartTransactionsController: SmartTransactionsController; initMessenger: TransactionControllerInitMessenger; @@ -224,7 +237,15 @@ async function publishHook({ const { isExternalSign } = transactionMeta; - if (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) { + const keyringSupports7702 = await accountSupports7702( + transactionMeta.txParams?.from, + keyringController, + ); + + if ( + keyringSupports7702 && + (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) + ) { const hook = new Delegation7702PublishHook({ isAtomicBatchSupported: transactionController.isAtomicBatchSupported.bind( transactionController, diff --git a/app/util/transactions/account-supports-7702.test.ts b/app/util/transactions/account-supports-7702.test.ts new file mode 100644 index 00000000000..cb9402df523 --- /dev/null +++ b/app/util/transactions/account-supports-7702.test.ts @@ -0,0 +1,115 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; +import { accountSupports7702 } from './account-supports-7702'; + +const SAMPLE_ADDRESS = '0x0000000000000000000000000000000000000001'; + +function createMockKeyringController(keyring: unknown): { + getKeyringForAccount: jest.Mock; +} { + return { + getKeyringForAccount: jest.fn().mockResolvedValue(keyring), + }; +} + +describe('accountSupports7702', () => { + it('returns true when address is undefined', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(undefined, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true when address is empty', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702('', controller)).resolves.toBe(true); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true for HD Key Tree keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); + + it('returns true for Simple Key Pair keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.simple, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('returns false for Ledger hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false for QR hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.qr, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring type is not in the allowlist', async () => { + const controller = createMockKeyringController({ + type: 'Snap Keyring', + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring has no string type', async () => { + const controller = createMockKeyringController({ type: 123 }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring is null', async () => { + const controller = createMockKeyringController(null); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns true when getKeyringForAccount throws', async () => { + const controller = { + getKeyringForAccount: jest.fn().mockRejectedValue(new Error('not found')), + }; + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('resolves the controller from a getter when a function is passed', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect( + accountSupports7702(SAMPLE_ADDRESS, () => controller), + ).resolves.toBe(true); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); +}); diff --git a/app/util/transactions/account-supports-7702.ts b/app/util/transactions/account-supports-7702.ts new file mode 100644 index 00000000000..e04243fd8b9 --- /dev/null +++ b/app/util/transactions/account-supports-7702.ts @@ -0,0 +1,51 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; + +/** Minimal shape; KeyringController.getKeyringForAccount is typed as Promise. */ +interface KeyringControllerLike { + getKeyringForAccount: (address: string) => Promise; +} + +/** + * Keyring types that support EIP-7702 (Setup Smart Account). + * Only HD (entropy) and simple (private key) accounts support this; hardware and snap do not. + */ +const KEYRING_TYPES_SUPPORTING_7702: string[] = [ + ExtendedKeyringTypes.hd, + ExtendedKeyringTypes.simple, +]; + +/** + * Returns whether the given account's keyring supports EIP-7702 gas fee tokens. + * Used to avoid requesting 7702 from sentinel for hardware and other unsupported keyrings. + * + * @param address - Account address (e.g. request.from or transactionMeta.txParams?.from). + * @param keyringControllerOrGetter - KeyringController instance or a function that returns it. + * @returns True if the account supports 7702 (or address is missing / lookup fails; assume supported). + */ +export async function accountSupports7702( + address: string | undefined, + keyringControllerOrGetter: + | KeyringControllerLike + | (() => KeyringControllerLike), +): Promise { + if (!address) { + return true; + } + const keyringController = + typeof keyringControllerOrGetter === 'function' + ? keyringControllerOrGetter() + : keyringControllerOrGetter; + try { + const keyring = await keyringController.getKeyringForAccount(address); + const keyringType = + keyring && + typeof keyring === 'object' && + 'type' in keyring && + typeof (keyring as { type: unknown }).type === 'string' + ? (keyring as { type: string }).type + : ''; + return KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + } catch { + return true; + } +} diff --git a/package.json b/package.json index 1fd95d9ba02..cda2f712c30 100644 --- a/package.json +++ b/package.json @@ -184,8 +184,10 @@ "viem": "2.31.3", "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", + "@metamask/bridge-status-controller@npm:^69.0.0": "patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch", "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", + "@metamask/bridge-status-controller@npm:^68.1.0": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { @@ -216,7 +218,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^69.1.1", - "@metamask/bridge-status-controller": "^68.1.0", + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/compliance-controller": "^1.0.1", "@metamask/connectivity-controller": "^0.1.0", diff --git a/yarn.lock b/yarn.lock index c98b0ac0f33..3a709443f69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7888,7 +7888,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^68.1.0": +"@metamask/bridge-status-controller@npm:68.1.0": version: 68.1.0 resolution: "@metamask/bridge-status-controller@npm:68.1.0" dependencies: @@ -7911,7 +7911,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^69.0.0": +"@metamask/bridge-status-controller@npm:69.0.0": version: 69.0.0 resolution: "@metamask/bridge-status-controller@npm:69.0.0" dependencies: @@ -7934,6 +7934,52 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch": + version: 68.1.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch::version=68.1.0&hash=358095" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.0.3" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/e2fb1a7667030e5d486e0c70c5f673223a1f48b488490ee4fbcf662116111936596c447bddbcc75b166d58d3c726c5e5892578213db8b9206891e78a4f03c136 + languageName: node + linkType: hard + +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch": + version: 69.0.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch::version=69.0.0&hash=41006d" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/da79a48e1fbae222f682aedfe008f6c0ef1213c78ff0faa85e57d59258e517477456a2cab3ef09a68b44cefa68bd515c0b4b46a06294998bebeec269b40920d7 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -35536,7 +35582,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" "@metamask/bridge-controller": "npm:^69.1.1" - "@metamask/bridge-status-controller": "npm:^68.1.0" + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0"