diff --git a/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch b/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch deleted file mode 100644 index 82d2534242c5..000000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch +++ /dev/null @@ -1,82 +0,0 @@ -diff --git a/dist/token-prices-service/codefi-v2.cjs b/dist/token-prices-service/codefi-v2.cjs -index ba0f0c1bcbf0f231549b1ca9d3be2d1137a0d732..fd4851471fa0c2f07efbb527a3eea55cbfbc4743 100644 ---- a/dist/token-prices-service/codefi-v2.cjs -+++ b/dist/token-prices-service/codefi-v2.cjs -@@ -220,43 +222,43 @@ exports.getNativeTokenAddress = getNativeTokenAddress; - // Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts - // We can only support PricesAPI V3 for EVM chains that have a CAIP-19 native asset mapping. - exports.SPOT_PRICES_SUPPORT_INFO = { -- '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH -- '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH -- '0x19': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO -- '0x38': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB -- '0x39': 'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS -+ '0x1': null, //'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH -+ '0xa': null, //'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH -+ '0x19': null, //'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO -+ '0x38': null, //'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB -+ '0x39': null, //'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS - '0x52': null, // 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR -- '0x58': 'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO -- '0x64': 'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI -- '0x6a': 'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX -- '0x80': 'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT -- '0x89': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL -+ '0x58': null, //'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO -+ '0x64': null, //'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI -+ '0x6a': null, //'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX -+ '0x80': null, //'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT -+ '0x89': null, //'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL - '0x8f': null, // 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON -- '0x92': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S -- '0xfa': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM -- '0x141': 'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS -- '0x144': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH -- '0x169': 'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL -- '0x3e7': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH -- '0x440': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS -- '0x44d': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH -- '0x504': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR -- '0x505': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR -- '0x531': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI -- '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT -- '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH -- '0x2710': 'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH -- '0xa4b1': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH -- '0xa4ec': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO -- '0xa516': 'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE -- '0xa86a': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX -- '0xe708': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH -- '0x13c31': 'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH -- '0x17dcd': 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU -+ '0x92': null, //'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S -+ '0xfa': null, //'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM -+ '0x141': null, //'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS -+ '0x144': null, //'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH -+ '0x169': null, //'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL -+ '0x3e7': null, //'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH -+ '0x440': null, //'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS -+ '0x44d': null, //'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH -+ '0x504': null, //'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR -+ '0x505': null, //'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR -+ '0x531': null, //'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI -+ '0x1388': null, //'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT -+ '0x2105': null, //'eip155:8453/slip44:60', // Base - Native symbol: ETH -+ '0x2710': null, //'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH -+ '0xa4b1': null, //'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH -+ '0xa4ec': null, //'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO -+ '0xa516': null, //'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE -+ '0xa86a': null, //'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX -+ '0xe708': null, //'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH -+ '0x13c31': null, //'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH -+ '0x17dcd': null, //'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU - '0x518af': null, // 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS -- '0x82750': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH -- '0x4e454152': 'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH -- '0x63564c40': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE -+ '0x82750': null, //'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH -+ '0x4e454152': null, //'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH -+ '0x63564c40': null, //'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE - }; - /** - * The list of chain IDs that can be supplied in the URL for the `/spot-prices` diff --git a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts index ecd746b6f76e..f3e1a29f33c3 100644 --- a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts +++ b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts @@ -33,4 +33,5 @@ export const mockBridgeReducerState: BridgeState = { isGasIncluded7702Supported: false, bridgeViewMode: BridgeViewMode.Bridge, isSelectingRecipient: false, + isDestTokenManuallySet: false, }; diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx index 2d7affb06777..390395a76663 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/BridgeDestTokenSelector.test.tsx @@ -9,6 +9,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { selectBridgeViewMode, setDestToken, + setIsDestTokenManuallySet, } from '../../../../../core/redux/slices/bridge'; import { cloneDeep } from 'lodash'; import { BridgeViewMode } from '../../types'; @@ -42,6 +43,7 @@ jest.mock('../../../../../core/redux/slices/bridge', () => { ...actual, default: actual.default, setDestToken: jest.fn(actual.setDestToken), + setIsDestTokenManuallySet: jest.fn(actual.setIsDestTokenManuallySet), selectBridgeViewMode: jest.fn().mockReturnValue('Bridge'), }; }); @@ -304,7 +306,8 @@ describe('BridgeDestTokenSelector', () => { // TODO: Fix flaky test - timing issue with debounced token selection (500ms) // Test fails intermittently due to race condition between waitFor and debounce - it.skip('handles token selection correctly', async () => { + // eslint-disable-next-line jest/no-disabled-tests + it.skip('handles token selection correctly and marks dest token as manually set', async () => { // Arrange const { getByText } = renderScreen( BridgeDestTokenSelector, @@ -325,7 +328,7 @@ describe('BridgeDestTokenSelector', () => { jest.advanceTimersByTime(500); }); - // Assert - check that actions were called + // Assert - check that setDestToken was called with the selected token expect(setDestToken).toHaveBeenCalledWith( expect.objectContaining({ address: ethToken2Address, @@ -337,6 +340,8 @@ describe('BridgeDestTokenSelector', () => { symbol: 'HELLO', }), ); + // Also verify the manual flag was set + expect(setIsDestTokenManuallySet).toHaveBeenCalledWith(true); expect(mockGoBack).toHaveBeenCalled(); }); diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx index b9054a8b70f2..8c8100a324dc 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/index.tsx @@ -10,6 +10,7 @@ import { selectSelectedDestChainId, selectSourceToken, setDestToken, + setIsDestTokenManuallySet, } from '../../../../../core/redux/slices/bridge'; import { getNetworkImageSource } from '../../../../../util/networks'; import { TokenSelectorItem } from '../TokenSelectorItem'; @@ -78,7 +79,9 @@ export const BridgeDestTokenSelector: React.FC = React.memo(() => { const handleTokenPress = useCallback( (token: BridgeToken) => { + // Mark as manually set to prevent auto-updating dest when source chain changes dispatch(setDestToken(token)); + dispatch(setIsDestTokenManuallySet(true)); navigation.goBack(); }, [dispatch, navigation], diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx index 7af160165bcf..6caaaf26f64d 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx @@ -6,7 +6,10 @@ import { BridgeSourceNetworkSelector } from '.'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; import { Hex } from '@metamask/utils'; -import { setSelectedSourceChainIds } from '../../../../../core/redux/slices/bridge'; +import { + setSelectedSourceChainIds, + setSourceToken, +} from '../../../../../core/redux/slices/bridge'; import { BridgeSourceNetworkSelectorSelectorsIDs } from '../../../../../../e2e/selectors/Bridge/BridgeSourceNetworkSelector.selectors'; import { cloneDeep } from 'lodash'; import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; @@ -35,19 +38,34 @@ jest.mock('../../../../../core/redux/slices/bridge', () => { }; }); +const mockOnSetRpcTarget = jest.fn().mockResolvedValue(undefined); +const mockOnNonEvmNetworkChange = jest.fn().mockResolvedValue(undefined); + jest.mock('../../../../Views/NetworkSelector/useSwitchNetworks', () => ({ useSwitchNetworks: jest.fn(() => ({ - onSetRpcTarget: jest.fn().mockResolvedValue(undefined), + onSetRpcTarget: mockOnSetRpcTarget, onNetworkChange: jest.fn(), + onNonEvmNetworkChange: mockOnNonEvmNetworkChange, })), })); +const mockAutoUpdateDestToken = jest.fn(); + +jest.mock('../../hooks/useAutoUpdateDestToken', () => ({ + useAutoUpdateDestToken: () => ({ + autoUpdateDestToken: mockAutoUpdateDestToken, + }), +})); + describe('BridgeSourceNetworkSelector', () => { const mockChainId = '0x1' as Hex; const optimismChainId = '0xa' as Hex; beforeEach(() => { jest.clearAllMocks(); + mockOnSetRpcTarget.mockResolvedValue(undefined); + mockOnNonEvmNetworkChange.mockResolvedValue(undefined); + mockAutoUpdateDestToken.mockClear(); }); it('renders with initial state and displays networks', async () => { @@ -223,6 +241,183 @@ describe('BridgeSourceNetworkSelector', () => { expect(applyButton.props.disabled).toBe(true); }); + describe('handleApply', () => { + it('calls onApply callback when provided instead of dispatching actions', async () => { + const mockOnApply = jest.fn(); + const { getAllByTestId, getByText } = renderScreen( + () => , + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Uncheck Ethereum to have only Optimism selected + const ethereumCheckbox = getAllByTestId(`checkbox-${mockChainId}`)[0]; + fireEvent.press(ethereumCheckbox); + + // Click Apply button + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(mockOnApply).toHaveBeenCalledWith([ + optimismChainId, + SolScope.Mainnet, + BtcScope.Mainnet, + TrxScope.Mainnet, + ]); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(setSelectedSourceChainIds).not.toHaveBeenCalled(); + }); + }); + + it('sets source token to native token when single EVM network is selected', async () => { + const { getAllByTestId, getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Deselect all first + const deselectAllButton = getByText('Deselect all'); + fireEvent.press(deselectAllButton); + + // Select only Ethereum + const ethereumCheckbox = getAllByTestId(`checkbox-${mockChainId}`)[0]; + fireEvent.press(ethereumCheckbox); + + // Click Apply + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(setSourceToken).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: mockChainId, + symbol: 'ETH', + }), + ); + }); + }); + + it('calls autoUpdateDestToken when single network is selected', async () => { + const { getAllByTestId, getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Deselect all first + const deselectAllButton = getByText('Deselect all'); + fireEvent.press(deselectAllButton); + + // Select only Optimism + const optimismCheckbox = getAllByTestId(`checkbox-${optimismChainId}`)[0]; + fireEvent.press(optimismCheckbox); + + // Click Apply + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(mockAutoUpdateDestToken).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: optimismChainId, + }), + ); + }); + }); + + it('calls onSetRpcTarget when single EVM network is selected', async () => { + const { getAllByTestId, getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Deselect all first + const deselectAllButton = getByText('Deselect all'); + fireEvent.press(deselectAllButton); + + // Select only Ethereum + const ethereumCheckbox = getAllByTestId(`checkbox-${mockChainId}`)[0]; + fireEvent.press(ethereumCheckbox); + + // Click Apply + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(mockOnSetRpcTarget).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: mockChainId, + }), + ); + }); + }); + + it('calls onNonEvmNetworkChange when single non-EVM network is selected', async () => { + const { getAllByTestId, getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Deselect all first + const deselectAllButton = getByText('Deselect all'); + fireEvent.press(deselectAllButton); + + // Select only Solana + const solanaCheckbox = getAllByTestId(`checkbox-${SolScope.Mainnet}`)[0]; + fireEvent.press(solanaCheckbox); + + // Click Apply + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(mockOnNonEvmNetworkChange).toHaveBeenCalledWith( + SolScope.Mainnet, + ); + expect(mockOnSetRpcTarget).not.toHaveBeenCalled(); + }); + }); + + it('does not set source token or switch network when multiple networks are selected', async () => { + const { getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state: initialState }, + ); + + // Keep all networks selected and click Apply + const applyButton = getByText('Apply'); + fireEvent.press(applyButton); + + await waitFor(() => { + expect(setSelectedSourceChainIds).toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalled(); + }); + + // These should NOT be called when multiple networks are selected + expect(setSourceToken).not.toHaveBeenCalled(); + expect(mockAutoUpdateDestToken).not.toHaveBeenCalled(); + expect(mockOnSetRpcTarget).not.toHaveBeenCalled(); + expect(mockOnNonEvmNetworkChange).not.toHaveBeenCalled(); + }); + }); + it('networks should be sorted by fiat value in descending order', async () => { const { getAllByTestId } = renderScreen( BridgeSourceNetworkSelector, diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx index b2fb7741cc63..04bf46f26ee4 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx @@ -34,6 +34,7 @@ import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selector import { getNativeSourceToken } from '../../utils/tokenUtils'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../selectors/featureFlagController/gasFeesSponsored'; import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; +import { useAutoUpdateDestToken } from '../../hooks/useAutoUpdateDestToken'; const createStyles = () => StyleSheet.create({ @@ -96,6 +97,7 @@ export const BridgeSourceNetworkSelector: React.FC< const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const { autoUpdateDestToken } = useAutoUpdateDestToken(); // Local state for candidate network selections const [candidateSourceChainIds, setCandidateSourceChainIds] = useState< @@ -141,14 +143,16 @@ export const BridgeSourceNetworkSelector: React.FC< // If there's only 1 network selected, set the source token to native token of that chain and switch chains if (newSelectedSourceChainids.length === 1) { + const newSourceChainId = newSelectedSourceChainids[0] as + | Hex + | CaipChainId; + const newSourceToken = getNativeSourceToken(newSourceChainId); + // Reset the source token - dispatch( - setSourceToken( - getNativeSourceToken( - newSelectedSourceChainids[0] as Hex | CaipChainId, - ), - ), - ); + dispatch(setSourceToken(newSourceToken)); + + // Auto-update destination token when source chain changes AND dest wasn't manually set + autoUpdateDestToken(newSourceToken); const evmNetworkConfiguration = evmNetworkConfigurations[newSelectedSourceChainids[0] as Hex]; @@ -171,6 +175,7 @@ export const BridgeSourceNetworkSelector: React.FC< enabledSourceChainIds, evmNetworkConfigurations, onSetRpcTarget, + autoUpdateDestToken, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) onNonEvmNetworkChange, ///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/index.tsx b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/index.tsx index 12a621d3d9dd..c1ede3f1eff6 100644 --- a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/index.tsx @@ -34,6 +34,7 @@ import { BridgeToken, BridgeViewMode } from '../../types'; import { useSwitchNetworks } from '../../../../Views/NetworkSelector/useSwitchNetworks'; import { useNetworkInfo } from '../../../../../selectors/selectedNetworkController'; import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; +import { useAutoUpdateDestToken } from '../../hooks/useAutoUpdateDestToken'; export const BridgeSourceTokenSelector: React.FC = React.memo(() => { const dispatch = useDispatch(); @@ -50,6 +51,7 @@ export const BridgeSourceTokenSelector: React.FC = React.memo(() => { const selectedSourceToken = useSelector(selectSourceToken); const selectedDestToken = useSelector(selectDestToken); const selectedChainId = useSelector(selectChainId); + const { autoUpdateDestToken } = useAutoUpdateDestToken(); const { chainId: selectedEvmChainId, // Will be the most recently selected EVM chain if you are on Solana @@ -99,6 +101,8 @@ export const BridgeSourceTokenSelector: React.FC = React.memo(() => { // and also the next time you open up the token selector to fetch top tokens for the right chain navigation.goBack(); dispatch(setSourceToken(token)); + // Auto-update destination token when source chain changes AND dest wasn't manually set + autoUpdateDestToken(token); // Switch to the chain of the selected token const evmNetworkConfiguration = @@ -119,6 +123,7 @@ export const BridgeSourceTokenSelector: React.FC = React.memo(() => { dispatch, evmNetworkConfigurations, onSetRpcTarget, + autoUpdateDestToken, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) onNonEvmNetworkChange, ///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/index.ts b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/index.ts new file mode 100644 index 000000000000..e4e1275e5731 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/index.ts @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { BtcScope, EthScope } from '@metamask/keyring-api'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { + setDestToken, + selectDestToken, + selectIsDestTokenManuallySet, +} from '../../../../../core/redux/slices/bridge'; +import { + getDefaultDestToken, + getNativeSourceToken, +} from '../../utils/tokenUtils'; +import { areAddressesEqual } from '../../../../../util/address'; +import { BridgeToken } from '../../types'; + +/** + * Hook that provides a function to auto-update the destination token when the source changes. + * Assume same-chain swap when user hasn't explicitly set dest. + * + * The dest token is updated if: + * 1. The source chain has changed (new source is on different chain than current dest) + * 2. Same-chain but dest needs correction (e.g., dest is native fallback but source no longer conflicts with default dest, or source now conflicts with current dest) + * 3. The user hasn't manually selected a destination token + * + * Special cases: + * - Bitcoin source: defaults to ETH on Ethereum (same-chain swaps don't make sense for BTC) + * - If default dest token equals source token: falls back to native token + * - If no default dest exists for chain: falls back to native token + */ +export const useAutoUpdateDestToken = () => { + const dispatch = useDispatch(); + const selectedDestToken = useSelector(selectDestToken); + const isDestTokenManuallySet = useSelector(selectIsDestTokenManuallySet); + + const autoUpdateDestToken = useCallback( + (newSourceToken: BridgeToken) => { + // Never auto-update if dest was manually set by user + if (isDestTokenManuallySet) { + return; + } + + // No dest token set yet, nothing to update from + if (!selectedDestToken?.chainId) { + return; + } + + // For Bitcoin source, default to ETH on Ethereum + // (same-chain swaps don't make sense for BTC) + if (newSourceToken.chainId === BtcScope.Mainnet) { + if ( + !areAddressesEqual( + selectedDestToken.address, + getNativeSourceToken(EthScope.Mainnet).address, + ) || + formatChainIdToCaip(selectedDestToken.chainId) !== EthScope.Mainnet + ) { + dispatch(setDestToken(getNativeSourceToken(EthScope.Mainnet))); + } + return; + } + + // Calculate what the dest token should be based on the new source + const defaultDestToken = getDefaultDestToken(newSourceToken.chainId); + const nativeToken = getNativeSourceToken(newSourceToken.chainId); + + let expectedDestToken: BridgeToken | undefined; + + // Determine expected dest token + if ( + defaultDestToken && + !areAddressesEqual(newSourceToken.address, defaultDestToken.address) + ) { + // Default dest is valid (doesn't conflict with source) + expectedDestToken = defaultDestToken; + } else if ( + !areAddressesEqual(newSourceToken.address, nativeToken.address) + ) { + // Fall back to native token if: + // 1. No default dest token exists for this chain, OR + // 2. Default dest equals source token + expectedDestToken = nativeToken; + } + + // Only dispatch if expected dest is different from current dest + if ( + expectedDestToken && + (!areAddressesEqual( + selectedDestToken.address, + expectedDestToken.address, + ) || + formatChainIdToCaip(selectedDestToken.chainId) !== + formatChainIdToCaip(expectedDestToken.chainId)) + ) { + dispatch(setDestToken(expectedDestToken)); + } + }, + [dispatch, selectedDestToken, isDestTokenManuallySet], + ); + + return { autoUpdateDestToken }; +}; diff --git a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts new file mode 100644 index 000000000000..603c21aa959f --- /dev/null +++ b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts @@ -0,0 +1,463 @@ +import { + initialState, + ethChainId, + optimismChainId, +} from '../../_mocks_/initialState'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useAutoUpdateDestToken } from '.'; +import { BridgeToken } from '../../types'; +import { Hex } from '@metamask/utils'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +// eslint-disable-next-line import/no-namespace +import * as bridgeSlice from '../../../../../core/redux/slices/bridge'; +// eslint-disable-next-line import/no-namespace +import * as tokenUtils from '../../utils/tokenUtils'; + +describe('useAutoUpdateDestToken', () => { + const mockEthToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000001', + symbol: 'TOKEN', + name: 'Test Token', + decimals: 18, + chainId: ethChainId, + }; + + const mockOptimismToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000002', + symbol: 'OP_TOKEN', + name: 'Optimism Token', + decimals: 18, + chainId: optimismChainId, + }; + + const mockPolygonToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000003', + symbol: 'MATIC_TOKEN', + name: 'Polygon Token', + decimals: 18, + chainId: '0x89' as Hex, + }; + + const mockBtcToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'BTC', + name: 'Bitcoin', + decimals: 8, + chainId: BtcScope.Mainnet, + }; + + const mockSolToken: BridgeToken = { + address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + symbol: 'SOL', + name: 'Solana', + decimals: 9, + chainId: SolScope.Mainnet, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when source chain changes and dest was not manually set', () => { + it('updates dest token to default for new source chain', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockEthToken, + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Optimism (different chain from dest) + result.current.autoUpdateDestToken(mockOptimismToken); + + // Dest should be updated to Optimism's default (USDC) + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: optimismChainId, + }), + ); + }); + + it('sets ETH on Ethereum as dest when source is Bitcoin', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockSolToken, // Currently on Solana + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Bitcoin + result.current.autoUpdateDestToken(mockBtcToken); + + // Dest should be ETH on Ethereum (0x1) + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'ETH', + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', + }), + ); + }); + }); + + describe('when dest was manually set', () => { + it('does not update dest token when source chain changes', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockEthToken, + isDestTokenManuallySet: true, // User manually selected dest + }, + }, + }, + ); + + // Change source to Optimism (different chain from dest) + result.current.autoUpdateDestToken(mockOptimismToken); + + // Dest should NOT be updated because it was manually set + expect(setDestTokenSpy).not.toHaveBeenCalled(); + }); + }); + + describe('same-chain source token changes', () => { + it('does not update dest when current dest already matches expected dest', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + // Dest is already the default (mUSD for Ethereum) + const mUsdToken: BridgeToken = { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: ethChainId, + }; + + const anotherEthToken: BridgeToken = { + ...mockEthToken, + address: '0x0000000000000000000000000000000000000099', + symbol: 'ANOTHER', + }; + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mUsdToken, // Already the expected default + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to another token on same chain (Ethereum) + result.current.autoUpdateDestToken(anotherEthToken); + + // Dest should NOT be updated because it already matches expected (mUSD) + expect(setDestTokenSpy).not.toHaveBeenCalled(); + }); + + it('updates dest from native fallback to default when source no longer conflicts', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + // Current dest is native ETH (set as fallback because source was mUSD) + const nativeEthToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + chainId: ethChainId, + }; + + // New source is a different token that doesn't conflict with default (mUSD) + const nonConflictingSource: BridgeToken = { + address: '0x0000000000000000000000000000000000000099', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + chainId: ethChainId, + }; + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: nativeEthToken, // Native fallback + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to a non-conflicting token on same chain + result.current.autoUpdateDestToken(nonConflictingSource); + + // Dest should update to the default (mUSD) since source no longer conflicts + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'mUSD', + chainId: ethChainId, + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + }), + ); + }); + + it('updates dest to native when source changes to match default dest', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + // Current dest is the default mUSD + const mUsdToken: BridgeToken = { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: ethChainId, + }; + + // New source is also mUSD (conflicts with dest) + const mUsdSourceToken: BridgeToken = { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: ethChainId, + }; + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mUsdToken, + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to mUSD (same as current dest) + result.current.autoUpdateDestToken(mUsdSourceToken); + + // Dest should update to native ETH since source now conflicts with default + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'ETH', + chainId: ethChainId, + address: '0x0000000000000000000000000000000000000000', + }), + ); + }); + }); + + describe('when there is no current dest token', () => { + it('does not update dest token when destToken is undefined', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: undefined, + isDestTokenManuallySet: false, + }, + }, + }, + ); + + result.current.autoUpdateDestToken(mockOptimismToken); + + // Dest should NOT be updated because there's no current dest to compare + expect(setDestTokenSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when default dest token equals source token', () => { + it('falls back to native token for the chain', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + // Mock a source token that matches the default dest token address + // For Ethereum mainnet, the default dest is mUSD at 0xaca92e438df0b2401ff60da7e4337b687a2435da + const sourceTokenMatchingDefault: BridgeToken = { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: ethChainId, + }; + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockPolygonToken, // Currently on Polygon + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Ethereum with a token that matches default dest + result.current.autoUpdateDestToken(sourceTokenMatchingDefault); + + // Should fall back to native ETH since default dest (mUSD) matches source + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'ETH', + chainId: ethChainId, + address: '0x0000000000000000000000000000000000000000', + }), + ); + }); + }); + + describe('when chain has no default dest token configured', () => { + it('falls back to native token when getDefaultDestToken returns undefined', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + // Mock getDefaultDestToken to return undefined + const getDefaultDestTokenSpy = jest + .spyOn(tokenUtils, 'getDefaultDestToken') + .mockReturnValue(undefined); + + // Mock getNativeSourceToken to return a predictable native token + const mockNativeToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'NATIVE', + name: 'Native Token', + decimals: 18, + chainId: optimismChainId, + }; + const getNativeSourceTokenSpy = jest + .spyOn(tokenUtils, 'getNativeSourceToken') + .mockReturnValue(mockNativeToken); + + const mockSourceToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000001', + symbol: 'SOME_TOKEN', + name: 'Some Token', + decimals: 18, + chainId: optimismChainId, + }; + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockEthToken, // Currently on Ethereum (different chain) + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Optimism where default returns undefined + result.current.autoUpdateDestToken(mockSourceToken); + + // Should fall back to native token since no default exists + expect(setDestTokenSpy).toHaveBeenCalledWith(mockNativeToken); + + // Cleanup + getDefaultDestTokenSpy.mockRestore(); + getNativeSourceTokenSpy.mockRestore(); + }); + }); + + describe('cross-chain scenarios', () => { + it('updates dest when switching from EVM to Solana source', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockEthToken, // Currently on Ethereum + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Solana + result.current.autoUpdateDestToken(mockSolToken); + + // Dest should be updated to Solana's default (USDC) + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: SolScope.Mainnet, + }), + ); + }); + + it('updates dest when switching from Solana to EVM source', () => { + const setDestTokenSpy = jest.spyOn(bridgeSlice, 'setDestToken'); + + const { result } = renderHookWithProvider( + () => useAutoUpdateDestToken(), + { + state: { + ...initialState, + bridge: { + ...initialState.bridge, + destToken: mockSolToken, // Currently on Solana + isDestTokenManuallySet: false, + }, + }, + }, + ); + + // Change source to Ethereum + result.current.autoUpdateDestToken(mockEthToken); + + // Dest should be updated to Ethereum's default (mUSD) + expect(setDestTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: ethChainId, + }), + ); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 4c867162b941..c7b7960d6830 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -24,6 +24,7 @@ import { selectIsBridgeEnabledSourceFactory, setSourceToken, setDestToken, + setIsDestTokenManuallySet, } from '../../../../../core/redux/slices/bridge'; import { trace, TraceName } from '../../../../../util/trace'; import { useCurrentNetworkInfo } from '../../../../hooks/useCurrentNetworkInfo'; @@ -127,6 +128,11 @@ export const useSwapBridgeNavigation = ({ sourceToken = getNativeSourceToken(EthScope.Mainnet); } + // Reset the manual dest token flag on navigation so auto-update works correctly + // This ensures if user previously manually set dest, then closed and reopened the app, + // changing source token will still auto-update the dest token + dispatch(setIsDestTokenManuallySet(false)); + // Pre-populate Redux state before navigation to prevent empty button flash dispatch(setSourceToken(sourceToken)); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts index b809cba2d90e..5401ed8ed5f5 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts @@ -38,12 +38,20 @@ jest.mock('../../../../hooks/useMetrics', () => { }); const mockGetIsBridgeEnabledSource = jest.fn(() => true); -jest.mock('../../../../../core/redux/slices/bridge', () => ({ - ...jest.requireActual('../../../../../core/redux/slices/bridge'), - selectIsBridgeEnabledSourceFactory: jest.fn( - () => mockGetIsBridgeEnabledSource, - ), -})); +const mockSetIsDestTokenManuallySet = jest.fn(); +jest.mock('../../../../../core/redux/slices/bridge', () => { + const actual = jest.requireActual('../../../../../core/redux/slices/bridge'); + return { + ...actual, + selectIsBridgeEnabledSourceFactory: jest.fn( + () => mockGetIsBridgeEnabledSource, + ), + setIsDestTokenManuallySet: (...args: unknown[]) => { + mockSetIsDestTokenManuallySet(...args); + return actual.setIsDestTokenManuallySet(...args); + }, + }; +}); const mockGoToPortfolioBridge = jest.fn(); jest.mock('../useGoToPortfolioBridge', () => ({ @@ -146,6 +154,9 @@ describe('useSwapBridgeNavigation', () => { // Reset bridge enabled mock to default (enabled) mockGetIsBridgeEnabledSource.mockReturnValue(true); + + // Reset setIsDestTokenManuallySet mock + mockSetIsDestTokenManuallySet.mockClear(); }); it('uses native token when no token is provided', () => { @@ -317,6 +328,21 @@ describe('useSwapBridgeNavigation', () => { }); }); + it('resets isDestTokenManuallySet flag when navigating to swaps', () => { + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetIsDestTokenManuallySet).toHaveBeenCalledWith(false); + }); + it('uses home page filter network when no token is provided', () => { // Mock home page filter network as Polygon mockUseCurrentNetworkInfo.mockReturnValue({ diff --git a/app/components/UI/Bridge/hooks/useSwitchTokens/index.ts b/app/components/UI/Bridge/hooks/useSwitchTokens/index.ts index be5c28e6b681..814f7b7fe91b 100644 --- a/app/components/UI/Bridge/hooks/useSwitchTokens/index.ts +++ b/app/components/UI/Bridge/hooks/useSwitchTokens/index.ts @@ -6,6 +6,7 @@ import { selectSourceToken, setSelectedDestChainId, setSourceAmount, + setIsDestTokenManuallySet, } from '../../../../../core/redux/slices/bridge'; import { useNetworkInfo } from '../../../../../selectors/selectedNetworkController'; import { useSwitchNetworks } from '../../../../Views/NetworkSelector/useSwitchNetworks'; @@ -48,6 +49,8 @@ export const useSwitchTokens = () => { if (sourceToken && destToken) { dispatch(setSourceToken(destToken)); dispatch(setDestToken(sourceToken)); + // Mark dest as manually set since user explicitly chose to flip + dispatch(setIsDestTokenManuallySet(true)); // Swap amounts dispatch(setSourceAmount(destTokenAmount)); diff --git a/app/components/UI/DeleteWalletModal/styles.ts b/app/components/UI/DeleteWalletModal/styles.ts index dfe2b0452da5..6b43ec4567c9 100644 --- a/app/components/UI/DeleteWalletModal/styles.ts +++ b/app/components/UI/DeleteWalletModal/styles.ts @@ -80,7 +80,7 @@ export const createStyles = (colors: any) => warningText: { textAlign: 'left', width: '100%', - fontFamily: 'Geist Medium', + fontFamily: 'Geist-Medium', }, warningTextContainer: { flexDirection: 'column', diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts index 60beddd55867..57b62dbe8a04 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts @@ -1,4 +1,4 @@ -import { Platform, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import type { Theme } from '../../../../../util/theme/models'; export const styleSheet = (params: { theme: Theme }) => { @@ -24,8 +24,7 @@ export const styleSheet = (params: { theme: Theme }) => { }, heading: { marginBottom: 8, - fontFamily: - Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular', + fontFamily: 'MMSans-Regular', }, bodyText: { marginBottom: 32, diff --git a/app/components/UI/Perps/components/PerpsGTMModal/PerpsGTMModal.styles.ts b/app/components/UI/Perps/components/PerpsGTMModal/PerpsGTMModal.styles.ts index 463708ac0dbe..8e81a501843b 100644 --- a/app/components/UI/Perps/components/PerpsGTMModal/PerpsGTMModal.styles.ts +++ b/app/components/UI/Perps/components/PerpsGTMModal/PerpsGTMModal.styles.ts @@ -78,9 +78,7 @@ const createStyles = ( ? Platform.OS === 'ios' ? 'System' : 'Roboto' - : Platform.OS === 'ios' - ? 'MM Poly' - : 'MM Poly Regular', + : 'MMPoly-Regular', fontWeight: useSystemFont ? '700' : Platform.OS === 'ios' diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts index 126904a6e68b..2927e7d86453 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts @@ -75,7 +75,7 @@ const createStyles = (theme: Theme) => flex: 1, }, title: { - fontFamily: Platform.OS === 'ios' ? 'MM Poly' : 'MM Poly Regular', + fontFamily: 'MMPoly-Regular', fontWeight: '400', // make it smaller on smaller screens fontSize: diff --git a/app/components/UI/ReviewModal/styles.ts b/app/components/UI/ReviewModal/styles.ts index 78f6b5b93322..28156e4c40bf 100644 --- a/app/components/UI/ReviewModal/styles.ts +++ b/app/components/UI/ReviewModal/styles.ts @@ -20,7 +20,7 @@ export const createStyles = (colors: any) => optionIcon: { fontSize: 24 }, optionLabel: { fontSize: 14, - fontFamily: 'Geist Regular', + fontFamily: 'Geist-Regular', color: colors.primary.default, }, helpOption: { marginVertical: 12 }, @@ -29,14 +29,14 @@ export const createStyles = (colors: any) => questionLabel: { fontSize: 18, paddingHorizontal: 30, - fontFamily: 'Geist Bold', + fontFamily: 'Geist-Bold', textAlign: 'center', color: colors.text.default, lineHeight: 26, }, description: { fontSize: 14, - fontFamily: 'Geist Regular', + fontFamily: 'Geist-Regular', color: colors.text.alternative, textAlign: 'center', lineHeight: 20, diff --git a/app/components/UI/Rewards/components/Onboarding/OnboardingIntroStep.tsx b/app/components/UI/Rewards/components/Onboarding/OnboardingIntroStep.tsx index 3e88afcce09b..122fda0b7f8f 100644 --- a/app/components/UI/Rewards/components/Onboarding/OnboardingIntroStep.tsx +++ b/app/components/UI/Rewards/components/Onboarding/OnboardingIntroStep.tsx @@ -5,7 +5,7 @@ import React, { useRef, useState, } from 'react'; -import { Image, ImageBackground, Platform, Text as RNText } from 'react-native'; +import { Image, ImageBackground, Text as RNText } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -318,7 +318,7 @@ const OnboardingIntroStep: React.FC<{ tw.style('text-center text-white text-12 leading-1 pt-1'), // eslint-disable-next-line react-native/no-inline-styles { - fontFamily: Platform.OS === 'ios' ? 'MM Poly' : 'MM Poly Regular', + fontFamily: 'MMPoly-Regular', }, ]} > diff --git a/app/components/Views/Onboarding/styles.ts b/app/components/Views/Onboarding/styles.ts index 575142b3e2e7..aa81963a718b 100644 --- a/app/components/Views/Onboarding/styles.ts +++ b/app/components/Views/Onboarding/styles.ts @@ -1,4 +1,4 @@ -import { StyleSheet, Platform } from 'react-native'; +import { StyleSheet } from 'react-native'; import Device from '../../../util/device'; import { colors as importedColors } from '../../../styles/common'; import type { Theme } from '../../../util/theme/models'; @@ -64,8 +64,7 @@ export const createStyles = (colors: Theme['colors']) => lineHeight: Device.isMediumDevice() ? 30 : 40, textAlign: 'center', paddingHorizontal: Device.isMediumDevice() ? 40 : 60, - fontFamily: - Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular', + fontFamily: 'MMSans-Regular', color: importedColors.gettingStartedTextColor, width: '100%', marginVertical: 16, diff --git a/app/components/Views/OnboardingSuccess/index.styles.ts b/app/components/Views/OnboardingSuccess/index.styles.ts index 993d88f85a68..7445e3b02a90 100644 --- a/app/components/Views/OnboardingSuccess/index.styles.ts +++ b/app/components/Views/OnboardingSuccess/index.styles.ts @@ -1,4 +1,4 @@ -import { StyleSheet, Platform } from 'react-native'; +import { StyleSheet } from 'react-native'; import { ThemeColors } from '@metamask/design-tokens'; const createStyles = (colors: ThemeColors) => @@ -26,8 +26,7 @@ const createStyles = (colors: ThemeColors) => marginBottom: 16, marginHorizontal: 16, textAlign: 'center', - fontFamily: - Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular', + fontFamily: 'MMSans-Regular', }, footerLink: { paddingVertical: 8, diff --git a/app/components/Views/ResetPassword/index.js b/app/components/Views/ResetPassword/index.js index e7595bf24c3d..45241b19b117 100644 --- a/app/components/Views/ResetPassword/index.js +++ b/app/components/Views/ResetPassword/index.js @@ -542,6 +542,7 @@ class ResetPassword extends PureComponent { // Set biometrics for new password await Authentication.resetPassword(); + try { // compute and store the new authentication method const authData = await Authentication.componentAuthenticationType( @@ -549,6 +550,14 @@ class ResetPassword extends PureComponent { this.state.rememberMe, ); await Authentication.storePasswordWithFallback(password, authData); + if ( + Authentication.authData.currentAuthType === + AUTHENTICATION_TYPE.BIOMETRIC + ) { + await updateAuthTypeStorageFlags(this.state.biometryChoice); + } else { + await updateAuthTypeStorageFlags(false); + } } catch (error) { Logger.error(error); } @@ -624,7 +633,6 @@ class ResetPassword extends PureComponent { }; updateBiometryChoice = async (biometryChoice) => { - await updateAuthTypeStorageFlags(biometryChoice); this.setState({ biometryChoice }); }; diff --git a/app/components/Views/ResetPassword/index.test.tsx b/app/components/Views/ResetPassword/index.test.tsx index 6b1115fa5c77..e9d8fb05d021 100644 --- a/app/components/Views/ResetPassword/index.test.tsx +++ b/app/components/Views/ResetPassword/index.test.tsx @@ -47,6 +47,13 @@ jest.mock('../../../core/Engine', () => ({ jest.mock('lottie-react-native', () => 'LottieView'); +const mockUpdateAuthTypeStorageFlags = jest.fn().mockResolvedValue(undefined); +jest.mock('../../../util/authentication', () => ({ + ...jest.requireActual('../../../util/authentication'), + updateAuthTypeStorageFlags: (biometryChoice: boolean) => + mockUpdateAuthTypeStorageFlags(biometryChoice), +})); + jest.mock('../../../store/storage-wrapper', () => ({ setItem: jest.fn(), getItem: jest.fn().mockResolvedValue(null), // Mock to return null to avoid biometrics interference @@ -65,6 +72,7 @@ jest.mock('../../../core/Authentication', () => ({ getPassword: jest.fn().mockResolvedValue(null), resetPassword: jest.fn().mockResolvedValue(undefined), storePassword: jest.fn().mockResolvedValue(undefined), + storePasswordWithFallback: jest.fn().mockResolvedValue(undefined), newWalletAndKeychain: jest .fn() .mockImplementation( @@ -77,6 +85,9 @@ jest.mock('../../../core/Authentication', () => ({ ), checkIsSeedlessPasswordOutdated: jest.fn().mockResolvedValue(false), lockApp: jest.fn().mockResolvedValue(undefined), + authData: { + currentAuthType: 'passcode', + }, })); jest.mock('../../../core/NavigationService', () => ({ @@ -1004,4 +1015,120 @@ describe('ResetPassword', () => { ); }); }); + + describe('biometry choice storage', () => { + it('saves biometry choice as false when auth type is not biometric', async () => { + mockUpdateAuthTypeStorageFlags.mockClear(); + + const component = await renderConfirmPasswordView(); + + const newPasswordInput = component.getByTestId( + ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID, + ); + + await act(async () => { + fireEvent.changeText(newPasswordInput, 'NewPassword123'); + }); + + const confirmPasswordInput = component.getByTestId( + ChoosePasswordSelectorsIDs.CONFIRM_PASSWORD_INPUT_ID, + ); + + await act(async () => { + fireEvent.changeText(confirmPasswordInput, 'NewPassword123'); + }); + + const submitButton = component.getByTestId( + ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID, + ); + + await act(async () => { + fireEvent.press(submitButton); + }); + + await waitFor(() => { + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + const navigationCall = ( + NavigationService.navigation.navigate as jest.Mock + ).mock.calls[0]; + const onPrimaryButtonPress = + navigationCall[1].params.onPrimaryButtonPress; + + await act(async () => { + await onPrimaryButtonPress(); + }); + + await waitFor(() => { + expect(mockUpdateAuthTypeStorageFlags).toHaveBeenCalledWith(false); + }); + }); + + it('saves biometry choice when auth type is biometric', async () => { + mockUpdateAuthTypeStorageFlags.mockClear(); + + const mockAuthModule = jest.requireMock('../../../core/Authentication'); + const originalAuthData = mockAuthModule.authData; + mockAuthModule.authData = { + currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, + }; + + const component = await renderConfirmPasswordView(); + + const newPasswordInput = component.getByTestId( + ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID, + ); + + await act(async () => { + fireEvent.changeText(newPasswordInput, 'NewPassword123'); + }); + + const confirmPasswordInput = component.getByTestId( + ChoosePasswordSelectorsIDs.CONFIRM_PASSWORD_INPUT_ID, + ); + + await act(async () => { + fireEvent.changeText(confirmPasswordInput, 'NewPassword123'); + }); + + const submitButton = component.getByTestId( + ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID, + ); + + await act(async () => { + fireEvent.press(submitButton); + }); + + await waitFor(() => { + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + const navigationCall = ( + NavigationService.navigation.navigate as jest.Mock + ).mock.calls[0]; + const onPrimaryButtonPress = + navigationCall[1].params.onPrimaryButtonPress; + + await act(async () => { + await onPrimaryButtonPress(); + }); + + await waitFor(() => { + expect(mockUpdateAuthTypeStorageFlags).toHaveBeenCalled(); + }); + + mockAuthModule.authData = originalAuthData; + }); + }); }); diff --git a/app/components/Views/confirmations/components/gas/gas-option/gas-option.styles.ts b/app/components/Views/confirmations/components/gas/gas-option/gas-option.styles.ts index 8d6ff9e6545d..166c0f33d5ff 100644 --- a/app/components/Views/confirmations/components/gas/gas-option/gas-option.styles.ts +++ b/app/components/Views/confirmations/components/gas/gas-option/gas-option.styles.ts @@ -35,11 +35,7 @@ const styleSheet = (params: { theme: Theme }) => { leftSection: { flexDirection: 'row', alignItems: 'center', - }, - emoji: { - fontSize: 24, - marginRight: 12, - marginLeft: 6, + marginLeft: 8, }, optionTextContainer: { justifyContent: 'center', diff --git a/app/components/Views/confirmations/components/gas/gas-option/gas-option.test.tsx b/app/components/Views/confirmations/components/gas/gas-option/gas-option.test.tsx index b8a1380de623..78e30dd5cbb6 100644 --- a/app/components/Views/confirmations/components/gas/gas-option/gas-option.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-option/gas-option.test.tsx @@ -4,7 +4,6 @@ import { noop } from 'lodash'; import { GasOption } from './gas-option'; const mockGasOption = { - emoji: '🚀', estimatedTime: '', isSelected: false, key: 'fast', @@ -21,7 +20,6 @@ describe('GasOption', () => { ); expect(getByTestId('gas-option-fast')).toBeOnTheScreen(); - expect(getByText('🚀')).toBeOnTheScreen(); expect(getByText('Test gas option')).toBeOnTheScreen(); expect(getByText('< 0.0001')).toBeOnTheScreen(); expect(getByText('0.05')).toBeOnTheScreen(); diff --git a/app/components/Views/confirmations/components/gas/gas-option/gas-option.tsx b/app/components/Views/confirmations/components/gas/gas-option/gas-option.tsx index 107048d97a02..0a6e1d04765a 100644 --- a/app/components/Views/confirmations/components/gas/gas-option/gas-option.tsx +++ b/app/components/Views/confirmations/components/gas/gas-option/gas-option.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, TouchableOpacity, Text as RNText } from 'react-native'; +import { View, TouchableOpacity } from 'react-native'; import { useStyles } from '../../../../../../component-library/hooks'; import Text, { @@ -9,16 +9,8 @@ import { type GasOption as GasOptionType } from '../../../types/gas'; import styleSheet from './gas-option.styles'; export const GasOption = ({ option }: { option: GasOptionType }) => { - const { - onSelect, - name, - estimatedTime, - valueInFiat, - value, - emoji, - isSelected, - key, - } = option; + const { onSelect, name, estimatedTime, valueInFiat, value, isSelected, key } = + option; const { styles } = useStyles(styleSheet, {}); @@ -33,7 +25,6 @@ export const GasOption = ({ option }: { option: GasOptionType }) => { onPress={() => onSelect()} > - {emoji} {name} diff --git a/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.test.tsx b/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.test.tsx index 7cbb36c0e27c..7e73d0023e63 100644 --- a/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.test.tsx @@ -30,33 +30,10 @@ describe('GasSpeed', () => { jest.clearAllMocks(); }); - it('renders null when transaction metadata has no userFeeLevel', () => { - const stateWithoutUserFeeLevel = merge({}, transferTransactionStateMock, { - engine: { - backgroundState: { - TransactionController: { - transactions: [ - { - id: '56f60ff0-2bef-11f0-80ce-2f66f7fbd577', - userFeeLevel: undefined, - }, - ], - }, - }, - }, - }); - - const { queryByText } = renderWithProvider(, { - state: stateWithoutUserFeeLevel, - }); - - expect(queryByText(/🐢|🦊|🦍|🌐|⚙️/)).toBeNull(); - }); - it.each([ - [GasFeeEstimateLevel.Low, 'Low', /🐢.*Low.*~ 30 sec/], - [GasFeeEstimateLevel.Medium, 'Medium', /🦊.*Market.*~ 20 sec/], - [GasFeeEstimateLevel.High, 'High', /🦍.*Aggressive.*~ 10 sec/], + [GasFeeEstimateLevel.Low, 'Low', /.*Low.*~ 30 sec/], + [GasFeeEstimateLevel.Medium, 'Medium', /.*Market.*~ 20 sec/], + [GasFeeEstimateLevel.High, 'High', /.*Aggressive.*~ 10 sec/], ])( 'renders correct content for %s gas fee estimate level', (userFeeLevel, _levelName, expectedPattern) => { @@ -104,7 +81,7 @@ describe('GasSpeed', () => { state: stateWithDappSuggested, }); - expect(getByText('🌐 Site suggested')).toBeTruthy(); + expect(getByText('Site suggested')).toBeTruthy(); }); it('renders correct content for CUSTOM user fee level', () => { @@ -126,7 +103,7 @@ describe('GasSpeed', () => { state: stateWithCustom, }); - expect(getByText('⚙️ Advanced')).toBeTruthy(); + expect(getByText('Advanced')).toBeTruthy(); }); it('does not show estimated time for gas price estimate when Medium level is selected', () => { @@ -151,7 +128,7 @@ describe('GasSpeed', () => { state: stateWithGasPriceEstimate, }); - expect(getByText('🦊 Market')).toBeTruthy(); + expect(getByText('Market')).toBeTruthy(); expect(queryByText(/sec/)).toBeNull(); }); @@ -177,7 +154,7 @@ describe('GasSpeed', () => { state: stateWithFeeMarketEstimate, }); - expect(getByText(/🦍.*Aggressive.*~ 10 sec/)).toBeTruthy(); + expect(getByText(/.*Aggressive.*~ 10 sec/)).toBeTruthy(); }); it('handles unknown user fee level by defaulting to advanced', () => { @@ -199,7 +176,7 @@ describe('GasSpeed', () => { state: stateWithUnknownFeeLevel, }); - expect(getByText('⚙️ Advanced')).toBeTruthy(); + expect(getByText('Advanced')).toBeTruthy(); }); it('calls useGasFeeEstimates with correct networkClientId', () => { diff --git a/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.tsx b/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.tsx index cccff3b5a51e..13a146575174 100644 --- a/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.tsx +++ b/app/components/Views/confirmations/components/gas/gas-speed/gas-speed.tsx @@ -9,26 +9,9 @@ import { import Text from '../../../../../../component-library/components/Texts/Text/Text'; import { strings } from '../../../../../../../locales/i18n'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; -import { GasOptionIcon } from '../../../constants/gas'; import { useGasFeeEstimates } from '../../../hooks/gas/useGasFeeEstimates'; import { toHumanSeconds } from '../../../utils/time'; -const getEmoji = (userFeeLevel: UserFeeLevel | GasFeeEstimateLevel) => { - switch (userFeeLevel) { - case GasFeeEstimateLevel.Low: - return GasOptionIcon.LOW; - case GasFeeEstimateLevel.Medium: - return GasOptionIcon.MEDIUM; - case GasFeeEstimateLevel.High: - return GasOptionIcon.HIGH; - case UserFeeLevel.DAPP_SUGGESTED: - return GasOptionIcon.SITE_SUGGESTED; - case UserFeeLevel.CUSTOM: - default: - return GasOptionIcon.ADVANCED; - } -}; - const getText = (userFeeLevel: UserFeeLevel | GasFeeEstimateLevel) => { switch (userFeeLevel) { case UserFeeLevel.DAPP_SUGGESTED: @@ -86,7 +69,6 @@ export const GasSpeed = () => { transactionMeta.gasFeeEstimates?.type === GasFeeEstimateType.GasPrice && userFeeLevel === GasFeeEstimateLevel.Medium; - const emoji = getEmoji(userFeeLevel); const text = getText(userFeeLevel); const estimatedTime = getEstimatedTime( userFeeLevel, @@ -94,6 +76,5 @@ export const GasSpeed = () => { isGasPriceEstimateSelected, ); - // Intentionally no space between text and estimated time - return {`${emoji} ${text}${estimatedTime}`}; + return {`${text}${estimatedTime}`}; }; diff --git a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.test.tsx b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.test.tsx index 82796eed0e78..f158881fd8a2 100644 --- a/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/estimates-modal/estimates-modal.test.tsx @@ -10,7 +10,6 @@ jest.mock('../../../hooks/gas/useGasOptions', () => ({ return { options: [ { - emoji: '🚀', estimatedTime: '', isSelected: false, key: 'fast', @@ -44,9 +43,7 @@ describe('EstimatesModal', () => { // Header expect(getByText('Edit network fee')).toBeOnTheScreen(); - // Gas option expected to be rendered expect(getByTestId('gas-option-fast')).toBeOnTheScreen(); - expect(getByText('🚀')).toBeOnTheScreen(); expect(getByText('Test gas option')).toBeOnTheScreen(); expect(getByText('< 0.0001')).toBeOnTheScreen(); expect(getByText('0.05')).toBeOnTheScreen(); diff --git a/app/components/Views/confirmations/components/send/amount/amount.styles.ts b/app/components/Views/confirmations/components/send/amount/amount.styles.ts index 85bf4053f242..55e3536effd1 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.styles.ts +++ b/app/components/Views/confirmations/components/send/amount/amount.styles.ts @@ -64,7 +64,7 @@ export const styleSheet = (params: { inputText: { fontSize: getFontSizeForInputLength(contentLength), lineHeight: 75, - fontFamily: 'Geist Medium', + fontFamily: 'Geist-Medium', }, inputWrapper: { alignItems: 'center', diff --git a/app/components/Views/confirmations/constants/gas.ts b/app/components/Views/confirmations/constants/gas.ts index a6e5912c28be..203c8d98a2b3 100644 --- a/app/components/Views/confirmations/constants/gas.ts +++ b/app/components/Views/confirmations/constants/gas.ts @@ -4,13 +4,4 @@ export enum GasModalType { ADVANCED_GAS_PRICE = 'advancedGasPriceModal', } -export enum GasOptionIcon { - ADVANCED = '⚙️', - GAS_PRICE = '⛓️', - HIGH = '🦍', - LOW = '🐢', - MEDIUM = '🦊', - SITE_SUGGESTED = '🌐', -} - export const EMPTY_VALUE_STRING = '--'; diff --git a/app/components/Views/confirmations/hooks/gas/useAdvancedGasFeeOption.ts b/app/components/Views/confirmations/hooks/gas/useAdvancedGasFeeOption.ts index b19243631f26..6b7183babcfc 100644 --- a/app/components/Views/confirmations/hooks/gas/useAdvancedGasFeeOption.ts +++ b/app/components/Views/confirmations/hooks/gas/useAdvancedGasFeeOption.ts @@ -9,11 +9,7 @@ import { import { strings } from '../../../../../../locales/i18n'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useFeeCalculations } from './useFeeCalculations'; -import { - EMPTY_VALUE_STRING, - GasModalType, - GasOptionIcon, -} from '../../constants/gas'; +import { EMPTY_VALUE_STRING, GasModalType } from '../../constants/gas'; import { type GasOption } from '../../types/gas'; const HEX_ZERO = '0x0'; @@ -118,7 +114,6 @@ export const useAdvancedGasFeeOption = ({ const memoizedGasOption = useMemo( () => [ { - emoji: GasOptionIcon.ADVANCED, estimatedTime: '', isSelected: isAdvancedGasFeeSelected, key: 'advanced', diff --git a/app/components/Views/confirmations/hooks/gas/useDappSuggestedGasFeeOption.ts b/app/components/Views/confirmations/hooks/gas/useDappSuggestedGasFeeOption.ts index d5068ad61aee..bbd6028de1ed 100644 --- a/app/components/Views/confirmations/hooks/gas/useDappSuggestedGasFeeOption.ts +++ b/app/components/Views/confirmations/hooks/gas/useDappSuggestedGasFeeOption.ts @@ -7,7 +7,7 @@ import { import { strings } from '../../../../../../locales/i18n'; import { updateTransactionGasFees } from '../../../../../util/transaction-controller'; import { type GasOption } from '../../types/gas'; -import { EMPTY_VALUE_STRING, GasOptionIcon } from '../../constants/gas'; +import { EMPTY_VALUE_STRING } from '../../constants/gas'; import { MMM_ORIGIN } from '../../constants/confirmations'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useFeeCalculations } from './useFeeCalculations'; @@ -71,7 +71,6 @@ export const useDappSuggestedGasFeeOption = ({ }); options.push({ - emoji: GasOptionIcon.SITE_SUGGESTED, estimatedTime: undefined, isSelected: isDappSuggestedGasFeeSelected, key: 'site_suggested', diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts index a75daffe6d7b..0e2a7b278476 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts @@ -176,15 +176,12 @@ describe('useGasFeeEstimateLevelOptions', () => { expect(result.current.length).toEqual(3); - // Check low option const lowOption = result.current.find((option) => option.key === 'low'); expect(lowOption).toBeDefined(); expect(lowOption?.isSelected).toEqual(false); expect(lowOption?.value).toEqual('5'); expect(lowOption?.valueInFiat).toEqual('$5'); - expect(lowOption?.emoji).toEqual('🐢'); - // Check medium option const mediumOption = result.current.find( (option) => option.key === 'medium', ); @@ -192,15 +189,12 @@ describe('useGasFeeEstimateLevelOptions', () => { expect(mediumOption?.isSelected).toEqual(false); expect(mediumOption?.value).toEqual('10'); expect(mediumOption?.valueInFiat).toEqual('$10'); - expect(mediumOption?.emoji).toEqual('🦊'); - // Check high option const highOption = result.current.find((option) => option.key === 'high'); expect(highOption).toBeDefined(); expect(highOption?.isSelected).toEqual(true); expect(highOption?.value).toEqual('15'); expect(highOption?.valueInFiat).toEqual('$15'); - expect(highOption?.emoji).toEqual('🦍'); expect(mockCalculateGasEstimate).toHaveBeenCalledWith( expect.objectContaining({ @@ -507,4 +501,86 @@ describe('useGasFeeEstimateLevelOptions', () => { }); expect(mockHandleCloseModals).toHaveBeenCalled(); }); + + it('excludes high option when medium and high have identical fees for FeeMarket estimates', () => { + const mockHandleCloseModals = jest.fn(); + + const transactionWithFeeMarketEstimates = { + ...simpleSendTransaction, + id: 'test-id', + userFeeLevel: 'medium', + txParams: { + ...simpleSendTransaction.txParams, + type: TransactionEnvelopeType.feeMarket, + }, + gasFeeEstimates: { + type: GasFeeEstimateType.FeeMarket, + low: { + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, + medium: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x2', + }, + high: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x2', + }, + }, + } as unknown as TransactionMeta; + + mockUseTransactionMetadataRequest.mockReturnValue( + transactionWithFeeMarketEstimates, + ); + + mockUseGasFeeEstimates.mockReturnValue({ + gasFeeEstimates: { + low: { + minWaitTimeEstimate: 60000, + maxWaitTimeEstimate: 120000, + suggestedMaxPriorityFeePerGas: '1', + }, + medium: { + minWaitTimeEstimate: 30000, + maxWaitTimeEstimate: 60000, + suggestedMaxPriorityFeePerGas: '2', + }, + high: { + minWaitTimeEstimate: 15000, + maxWaitTimeEstimate: 30000, + suggestedMaxPriorityFeePerGas: '2', + }, + } as unknown as GasFeeEstimates, + }); + + mockCalculateGasEstimate.mockImplementation(({ feePerGas }) => { + const value = feePerGas === '0x1' ? '5' : '10'; + const valueInFiat = feePerGas === '0x1' ? '$5' : '$10'; + + return { + currentCurrencyFee: valueInFiat, + preciseNativeCurrencyFee: value, + }; + }); + + const { result } = renderHook(() => + useGasFeeEstimateLevelOptions({ + handleCloseModals: mockHandleCloseModals, + }), + ); + + expect(result.current.length).toEqual(2); + + const lowOption = result.current.find((option) => option.key === 'low'); + expect(lowOption).toBeDefined(); + + const mediumOption = result.current.find( + (option) => option.key === 'medium', + ); + expect(mediumOption).toBeDefined(); + + const highOption = result.current.find((option) => option.key === 'high'); + expect(highOption).toBeUndefined(); + }); }); diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts index cb8634139d31..272cd66b5d6a 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts @@ -14,16 +14,10 @@ import { toHumanEstimatedTimeRange } from '../../utils/time'; import { useFeeCalculations } from './useFeeCalculations'; import { updateTransactionGasFees } from '../../../../../util/transaction-controller'; import { type GasOption } from '../../types/gas'; -import { EMPTY_VALUE_STRING, GasOptionIcon } from '../../constants/gas'; +import { EMPTY_VALUE_STRING } from '../../constants/gas'; const HEX_ZERO = '0x0'; -const GasEstimateFeeLevelEmojis = { - [GasFeeEstimateLevel.Low]: GasOptionIcon.LOW, - [GasFeeEstimateLevel.Medium]: GasOptionIcon.MEDIUM, - [GasFeeEstimateLevel.High]: GasOptionIcon.HIGH, -}; - export const useGasFeeEstimateLevelOptions = ({ handleCloseModals, }: { @@ -64,6 +58,26 @@ export const useGasFeeEstimateLevelOptions = ({ if (shouldIncludeGasFeeEstimateLevelOptions) { Object.values(GasFeeEstimateLevel).forEach((level) => { + // Skip adding the high option if it has the same fees as the medium option + if ( + level === GasFeeEstimateLevel.High && + transactionGasFeeEstimates?.type === GasFeeEstimateType.FeeMarket + ) { + const mediumEstimates = + transactionGasFeeEstimates[GasFeeEstimateLevel.Medium]; + const highEstimates = + transactionGasFeeEstimates[GasFeeEstimateLevel.High]; + + const hasSameFees = + mediumEstimates?.maxFeePerGas === highEstimates?.maxFeePerGas && + mediumEstimates?.maxPriorityFeePerGas === + highEstimates?.maxPriorityFeePerGas; + + if (hasSameFees) { + return; + } + } + const estimatedTime = toHumanEstimatedTimeRange( networkGasFeeEstimates[level].minWaitTimeEstimate, networkGasFeeEstimates[level].maxWaitTimeEstimate, @@ -101,7 +115,6 @@ export const useGasFeeEstimateLevelOptions = ({ }); options.push({ - emoji: GasEstimateFeeLevelEmojis[level], estimatedTime, isSelected: userFeeLevel === level, key: level, diff --git a/app/components/Views/confirmations/hooks/gas/useGasOptions.test.ts b/app/components/Views/confirmations/hooks/gas/useGasOptions.test.ts index c52a5602fbdd..9873ee34b39b 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasOptions.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasOptions.test.ts @@ -23,7 +23,6 @@ describe('useGasOptions', () => { ); const mockAdvancedOption: GasOption = { - emoji: '⚙️', estimatedTime: '', isSelected: false, key: 'advanced', @@ -34,7 +33,6 @@ describe('useGasOptions', () => { }; const mockLowLevelOption: GasOption = { - emoji: '🐢', estimatedTime: '~1 min', isSelected: false, key: 'low', @@ -45,7 +43,6 @@ describe('useGasOptions', () => { }; const mockMediumLevelOption: GasOption = { - emoji: '🦊', estimatedTime: '~30 sec', isSelected: true, key: 'medium', @@ -56,7 +53,6 @@ describe('useGasOptions', () => { }; const mockGasPriceOption: GasOption = { - emoji: '⛽️', estimatedTime: '', isSelected: false, key: 'gasPrice', @@ -67,7 +63,6 @@ describe('useGasOptions', () => { }; const mockDappSuggestedOption: GasOption = { - emoji: '🌐', estimatedTime: '', isSelected: false, key: 'site_suggested', diff --git a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts index 622017c37a8c..1b0e7ce2d498 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts @@ -12,7 +12,7 @@ import { useTransactionMetadataRequest } from '../transactions/useTransactionMet import { useGasFeeEstimates } from './useGasFeeEstimates'; import { useFeeCalculations } from './useFeeCalculations'; import { type GasOption } from '../../types/gas'; -import { EMPTY_VALUE_STRING, GasOptionIcon } from '../../constants/gas'; +import { EMPTY_VALUE_STRING } from '../../constants/gas'; const HEX_ZERO = '0x0'; @@ -109,7 +109,6 @@ export const useGasPriceEstimateOption = ({ return [ { - emoji: GasOptionIcon.GAS_PRICE, estimatedTime: undefined, isSelected: isGasPriceEstimateSelected, key: 'gasPrice', diff --git a/app/components/Views/confirmations/types/gas.ts b/app/components/Views/confirmations/types/gas.ts index ccb347d4a6b9..8a747f95aeb3 100644 --- a/app/components/Views/confirmations/types/gas.ts +++ b/app/components/Views/confirmations/types/gas.ts @@ -1,5 +1,4 @@ export interface GasOption { - emoji: string; estimatedTime?: string; isSelected: boolean; key: string; diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index 1b81e2953385..a6568e8a1ebf 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -9,6 +9,8 @@ import reducer, { setBridgeViewMode, selectBridgeViewMode, setDestToken, + setIsDestTokenManuallySet, + selectIsDestTokenManuallySet, selectBip44DefaultPair, selectGasIncludedQuoteParams, } from '.'; @@ -44,7 +46,7 @@ describe('bridge slice', () => { }; describe('initial state', () => { - it('should have the correct initial state', () => { + it('has correct initial state', () => { expect(initialState).toEqual({ bridgeViewMode: undefined, sourceAmount: undefined, @@ -60,6 +62,7 @@ describe('bridge slice', () => { isSubmittingTx: false, isSelectingRecipient: false, isMaxSourceAmount: false, + isDestTokenManuallySet: false, }); }); }); @@ -170,13 +173,71 @@ describe('bridge slice', () => { }); describe('setDestToken', () => { - it('should set the destination token and update selectedDestChainId', () => { + it('sets the destination token and updates selectedDestChainId', () => { const action = setDestToken(mockDestToken); const state = reducer(initialState, action); expect(state.destToken).toBe(mockDestToken); expect(state.selectedDestChainId).toBe(mockDestToken.chainId); }); + + it('does not modify isDestTokenManuallySet flag', () => { + const stateWithManualFlag = { + ...initialState, + isDestTokenManuallySet: true, + }; + + const action = setDestToken(mockDestToken); + const state = reducer(stateWithManualFlag, action); + + expect(state.isDestTokenManuallySet).toBe(true); + }); + }); + + describe('setIsDestTokenManuallySet', () => { + it('sets the flag to true', () => { + const action = setIsDestTokenManuallySet(true); + const state = reducer(initialState, action); + + expect(state.isDestTokenManuallySet).toBe(true); + }); + + it('sets the flag to false', () => { + const stateWithManualFlag = { + ...initialState, + isDestTokenManuallySet: true, + }; + + const action = setIsDestTokenManuallySet(false); + const state = reducer(stateWithManualFlag, action); + + expect(state.isDestTokenManuallySet).toBe(false); + }); + }); + + describe('selectIsDestTokenManuallySet', () => { + it('returns false from initial state', () => { + const mockState = { + bridge: initialState, + } as RootState; + + const result = selectIsDestTokenManuallySet(mockState); + + expect(result).toBe(false); + }); + + it('returns true when flag is set', () => { + const mockState = { + bridge: { + ...initialState, + isDestTokenManuallySet: true, + }, + } as RootState; + + const result = selectIsDestTokenManuallySet(mockState); + + expect(result).toBe(true); + }); }); describe('resetBridgeState', () => { diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 007f94250c9d..c5d19317c7fe 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -59,6 +59,12 @@ export interface BridgeState { isSelectingRecipient: boolean; isGasIncludedSTXSendBundleSupported: boolean; isGasIncluded7702Supported: boolean; + /** + * Tracks whether the user has manually selected a destination token. + * When true, changing the source token to a different network won't auto-update the dest token. + * When false, changing source network will update dest to the default for that network. + */ + isDestTokenManuallySet: boolean; } export const initialState: BridgeState = { @@ -76,6 +82,7 @@ export const initialState: BridgeState = { isSelectingRecipient: false, isGasIncludedSTXSendBundleSupported: false, isGasIncluded7702Supported: false, + isDestTokenManuallySet: false, }; const name = 'bridge'; @@ -133,6 +140,13 @@ const slice = createSlice({ // Update selectedDestChainId to match the destination token's chain ID state.selectedDestChainId = action.payload.chainId; }, + /** + * Sets whether the destination token was manually selected by the user. + * When true, auto-updates to dest token are prevented when source chain changes. + */ + setIsDestTokenManuallySet: (state, action: PayloadAction) => { + state.isDestTokenManuallySet = action.payload; + }, setDestAddress: (state, action: PayloadAction) => { state.destAddress = action.payload; }, @@ -565,6 +579,11 @@ export const selectIsSelectingRecipient = createSelector( (bridgeState) => bridgeState.isSelectingRecipient, ); +export const selectIsDestTokenManuallySet = createSelector( + selectBridgeState, + (bridgeState) => bridgeState.isDestTokenManuallySet, +); + export const selectIsGaslessSwapEnabled = createSelector( selectIsSwap, selectBridgeFeatureFlags, @@ -625,6 +644,7 @@ export const { resetBridgeState, setSourceToken, setDestToken, + setIsDestTokenManuallySet, setSelectedSourceChainIds, setSelectedDestChainId, setSlippage, diff --git a/package.json b/package.json index ebe450ad581e..b4083ca3ef35 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "@metamask/address-book-controller": "^7.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch", + "@metamask/assets-controllers": "^93.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.8.0", "@metamask/bridge-controller": "patch:@metamask/bridge-controller@npm%3A61.0.0#~/.yarn/patches/@metamask-bridge-controller-npm-61.0.0-8c413c463f.patch", diff --git a/yarn.lock b/yarn.lock index 4f9321be1c22..c1e09248db82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7132,61 +7132,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:93.0.0": - version: 93.0.0 - resolution: "@metamask/assets-controllers@npm:93.0.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^4.0.0" - "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/core-backend": "npm:^5.0.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/keyring-controller": "npm:^25.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^4.0.0" - "@metamask/network-controller": "npm:^27.0.0" - "@metamask/permission-controller": "npm:^12.1.1" - "@metamask/phishing-controller": "npm:^16.1.0" - "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/preferences-controller": "npm:^22.0.0" - "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^14.0.1" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/transaction-controller": "npm:^62.4.0" - "@metamask/utils": "npm:^11.8.1" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/35b4294bacc2a2123d99af181c2482674386f21d89ae102934573810101ba97b71e5ff5a24038a09f3dd8ad07d12b5cabe7a29c9aa04bdd5847a83284240d217 - languageName: node - linkType: hard - -"@metamask/assets-controllers@npm:^93.1.0": +"@metamask/assets-controllers@npm:^93.0.0, @metamask/assets-controllers@npm:^93.1.0": version: 93.1.0 resolution: "@metamask/assets-controllers@npm:93.1.0" dependencies: @@ -7240,60 +7186,6 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch": - version: 93.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch::version=93.0.0&hash=e4c733" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^4.0.0" - "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/core-backend": "npm:^5.0.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/keyring-controller": "npm:^25.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^4.0.0" - "@metamask/network-controller": "npm:^27.0.0" - "@metamask/permission-controller": "npm:^12.1.1" - "@metamask/phishing-controller": "npm:^16.1.0" - "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/preferences-controller": "npm:^22.0.0" - "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^14.0.1" - "@metamask/snaps-sdk": "npm:^9.0.0" - "@metamask/snaps-utils": "npm:^11.0.0" - "@metamask/transaction-controller": "npm:^62.4.0" - "@metamask/utils": "npm:^11.8.1" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/9409c903dcdb56e1126a7f8777de82a88bc64b0470d73ddfffd0d165649df5191597c3bdbead31466dcccc023803c7fbecdbbdc9c027f45044ba5a278a5f1c82 - languageName: node - linkType: hard - "@metamask/auth-network-utils@npm:^0.3.0": version: 0.3.1 resolution: "@metamask/auth-network-utils@npm:0.3.1" @@ -34312,7 +34204,7 @@ __metadata: "@metamask/address-book-controller": "npm:^7.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A93.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-93.0.0-ea998cb0bd.patch" + "@metamask/assets-controllers": "npm:^93.0.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.8.0"