From 4d7e23860a49146b3fa8c4cae497b648ff55e193 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Mon, 3 Nov 2025 09:23:05 +0100 Subject: [PATCH 1/2] fix: Implement short living ens resolver for `Name` component after send flow (#21515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Similarly what's being done in https://github.com/MetaMask/metamask-extension/pull/37047 this PR implements a short living cache for `Name` component to use after send flow. ## **Changelog** CHANGELOG entry: Show ENS name in the confirmation after picking ENS recipient in send flow ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/19072 ## **Manual testing steps** 1. Go to send flow 2. Pick ENS recipient 3. Proceed into confirmation - you should be able to see ENS in the confirmation now ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/d74ee373-8df0-43d4-924a-5520eb203240 ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a 5‑minute ENS resolution cache used post-send and surfaces cached ENS in Name display; integrates with validation and updates tests. > > - **ENS caching (5 min TTL)**: > - Add `useSendFlowEnsResolutions` with `setResolvedAddress` and `getResolvedENSName` to cache ENS per `chainId:address`. > - **Send flow integration**: > - Update `useNameValidation` to call `setResolvedAddress(chainId, ensName, resolvedAddress)` when `isEvmSendType` and ENS resolves. > - **Display name integration**: > - Update `useDisplayName` to read `ensName` via `getResolvedENSName(variation, value)` and prioritize it in `name` selection. > - **Tests**: > - New tests for cache storage, expiration, chain separation, overwrite behavior. > - Extend `useNameValidation` tests to assert cache writes only for EVM/ENS. > - Add `useDisplayName` test to verify ENS name is returned. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c5a24a953b99e9eb590c277a91f422a7c86c7b85. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/send/useNameValidation.test.ts | 55 +++++++++++ .../hooks/send/useNameValidation.ts | 11 ++- .../send/useSendFlowEnsResolutions.test.ts | 93 +++++++++++++++++++ .../hooks/send/useSendFlowEnsResolutions.ts | 80 ++++++++++++++++ .../hooks/DisplayName/useDisplayName.test.ts | 33 +++++++ .../hooks/DisplayName/useDisplayName.ts | 6 +- 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.test.ts create mode 100644 app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.ts diff --git a/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts b/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts index ac8b27e606c..e7a3c69cc7b 100644 --- a/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useNameValidation.test.ts @@ -7,16 +7,41 @@ import * as SnapNameResolution from '../../../../Snaps/hooks/useSnapNameResoluti import * as SendValidationUtils from '../../utils/send-address-validations'; import { evmSendStateMock } from '../../__mocks__/send.mock'; import { useNameValidation } from './useNameValidation'; +import { useSendType } from './useSendType'; +import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions'; jest.mock('@metamask/bridge-controller', () => ({ formatChainIdToCaip: jest.fn(), })); +jest.mock('./useSendType', () => ({ + useSendType: jest.fn(), +})); + +jest.mock('./useSendFlowEnsResolutions', () => ({ + useSendFlowEnsResolutions: jest.fn(() => ({ + setResolvedAddress: jest.fn(), + })), +})); + const mockState = { state: evmSendStateMock, }; describe('useNameValidation', () => { + const mockUseSendType = jest.mocked(useSendType); + const mockUseSendFlowEnsResolutions = jest.mocked(useSendFlowEnsResolutions); + const mockSetResolvedAddress = jest.fn(); + + beforeEach(() => { + mockUseSendType.mockReturnValue({ + isEvmSendType: false, + } as ReturnType); + mockUseSendFlowEnsResolutions.mockReturnValue({ + setResolvedAddress: mockSetResolvedAddress, + } as unknown as ReturnType); + }); + it('return function to validate name', () => { const { result } = renderHookWithProvider( () => useNameValidation(), @@ -44,6 +69,36 @@ describe('useNameValidation', () => { ).toStrictEqual({ resolvedAddress: 'dummy_address', }); + expect(mockSetResolvedAddress).not.toHaveBeenCalled(); + }); + + it('calls setResolvedAddress when name is resolved and isEvmSendType is true', async () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: true, + } as ReturnType); + jest.spyOn(SnapNameResolution, 'useSnapNameResolution').mockReturnValue({ + fetchResolutions: () => + Promise.resolve([ + { resolvedAddress: 'dummy_address' } as unknown as AddressResolution, + ]), + }); + const { result } = renderHookWithProvider( + () => useNameValidation(), + mockState, + ); + expect( + await result.current.validateName( + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'test.eth', + ), + ).toStrictEqual({ + resolvedAddress: 'dummy_address', + }); + expect(mockSetResolvedAddress).toHaveBeenCalledWith( + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'test.eth', + 'dummy_address', + ); }); it('return confusable error and warning as name is resolved', async () => { diff --git a/app/components/Views/confirmations/hooks/send/useNameValidation.ts b/app/components/Views/confirmations/hooks/send/useNameValidation.ts index 2585de5c384..f4ae7a25bc7 100644 --- a/app/components/Views/confirmations/hooks/send/useNameValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useNameValidation.ts @@ -5,9 +5,13 @@ import { strings } from '../../../../../../locales/i18n'; import { isENS } from '../../../../../util/address'; import { useSnapNameResolution } from '../../../../Snaps/hooks/useSnapNameResolution'; import { getConfusableCharacterInfo } from '../../utils/send-address-validations'; +import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions'; +import { useSendType } from './useSendType'; export const useNameValidation = () => { const { fetchResolutions } = useSnapNameResolution(); + const { setResolvedAddress } = useSendFlowEnsResolutions(); + const { isEvmSendType } = useSendType(); const validateName = useCallback( async (chainId: string, to: string) => { @@ -24,6 +28,11 @@ export const useNameValidation = () => { } const resolvedAddress = resolutions[0]?.resolvedAddress; + if (resolvedAddress && isEvmSendType) { + // Set short living cache of ENS resolution for the given chain and address for confirmation screen + setResolvedAddress(chainId, to, resolvedAddress); + } + return { resolvedAddress, ...getConfusableCharacterInfo(to), @@ -34,7 +43,7 @@ export const useNameValidation = () => { error: strings('send.could_not_resolve_name'), }; }, - [fetchResolutions], + [fetchResolutions, isEvmSendType, setResolvedAddress], ); return { diff --git a/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.test.ts b/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.test.ts new file mode 100644 index 00000000000..0f6305d5d79 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.test.ts @@ -0,0 +1,93 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions'; + +describe('useSendFlowEnsResolutions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stores and retrieves ENS name for an address', () => { + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId = '0x1'; + const ensName = 'vitalik.eth'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + result.current.setResolvedAddress(chainId, ensName, address); + const retrieved = result.current.getResolvedENSName(chainId, address); + + expect(retrieved).toBe(ensName); + }); + + it('returns undefined for non-existent address', () => { + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId = '0x1'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96046'; + + const retrieved = result.current.getResolvedENSName(chainId, address); + + expect(retrieved).toBeUndefined(); + }); + + it('differentiates between different chain IDs', () => { + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId1 = '0x1'; + const chainId2 = '0x89'; + const ensName = 'vitalik.eth'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + result.current.setResolvedAddress(chainId1, ensName, address); + const retrieved1 = result.current.getResolvedENSName(chainId1, address); + const retrieved2 = result.current.getResolvedENSName(chainId2, address); + + expect(retrieved1).toBe(ensName); + expect(retrieved2).toBeUndefined(); + }); + + it('returns undefined for expired cache entries', () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId = '0x1'; + const ensName = 'vitalik.eth'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + result.current.setResolvedAddress(chainId, ensName, address); + + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + const retrieved = result.current.getResolvedENSName(chainId, address); + + expect(retrieved).toBeUndefined(); + jest.useRealTimers(); + }); + + it('returns cached entry before expiration', () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId = '0x1'; + const ensName = 'vitalik.eth'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + result.current.setResolvedAddress(chainId, ensName, address); + + jest.advanceTimersByTime(5 * 60 * 1000 - 1000); + + const retrieved = result.current.getResolvedENSName(chainId, address); + + expect(retrieved).toBe(ensName); + jest.useRealTimers(); + }); + + it('overwrites existing cache entry for same chain and address', () => { + const { result } = renderHook(() => useSendFlowEnsResolutions()); + const chainId = '0x1'; + const ensName1 = 'vitalik.eth'; + const ensName2 = 'newname.eth'; + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + + result.current.setResolvedAddress(chainId, ensName1, address); + result.current.setResolvedAddress(chainId, ensName2, address); + const retrieved = result.current.getResolvedENSName(chainId, address); + + expect(retrieved).toBe(ensName2); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.ts b/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.ts new file mode 100644 index 00000000000..05ea8fd19df --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/useSendFlowEnsResolutions.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; + +interface CacheEntry { + ensName: string; + timestamp: number; +} + +const CACHE_TTL = 5 * 60 * 1000; + +const ensResolutionCache = new Map(); + +const createCacheKey = (chainId: string, address: string) => + `${chainId}:${address}`; + +const isCacheEntryValid = (entry: CacheEntry): boolean => + Date.now() - entry.timestamp < CACHE_TTL; + +// This hook is used to store and retrieve ENS resolutions for a given chain and address with short living cache +// especially to keep consistency of Name component in send flow +export const useSendFlowEnsResolutions = () => { + const setResolvedAddress = useCallback( + (chainId: string, ensName: string, address: string) => { + if ( + !isValidString(chainId) || + !isValidString(address) || + !isValidString(ensName) + ) { + return; + } + + const lowerCaseChainId = chainId.toLowerCase(); + const lowerCaseAddress = address.toLowerCase(); + const lowerCaseEnsName = ensName.toLowerCase(); + + ensResolutionCache.set( + createCacheKey(lowerCaseChainId, lowerCaseAddress), + { + ensName: lowerCaseEnsName, + timestamp: Date.now(), + }, + ); + }, + [], + ); + + const getResolvedENSName = useCallback( + (chainId: string, address: string): string | undefined => { + if (!isValidString(chainId) || !isValidString(address)) { + return undefined; + } + + const lowerCaseChainId = chainId.toLowerCase(); + const lowerCaseAddress = address.toLowerCase(); + + const entry = ensResolutionCache.get( + createCacheKey(lowerCaseChainId, lowerCaseAddress), + ); + if (entry && isCacheEntryValid(entry)) { + return entry.ensName; + } + + if (entry) { + ensResolutionCache.delete( + createCacheKey(lowerCaseChainId, lowerCaseAddress), + ); + } + return undefined; + }, + [], + ); + + return { + setResolvedAddress, + getResolvedENSName, + }; +}; + +function isValidString(value: unknown) { + return typeof value === 'string' && value.length > 0; +} diff --git a/app/components/hooks/DisplayName/useDisplayName.test.ts b/app/components/hooks/DisplayName/useDisplayName.test.ts index 96356b2888f..811ff74d674 100644 --- a/app/components/hooks/DisplayName/useDisplayName.test.ts +++ b/app/components/hooks/DisplayName/useDisplayName.test.ts @@ -8,6 +8,7 @@ import { useWatchedNFTNames } from './useWatchedNFTNames'; import { useNftNames } from './useNftName'; import { useAccountNames } from './useAccountNames'; import { useAccountWalletNames } from './useAccountWalletNames'; +import { useSendFlowEnsResolutions } from '../../Views/confirmations/hooks/send/useSendFlowEnsResolutions'; const UNKNOWN_ADDRESS_CHECKSUMMED = '0x299007B3F9E23B8d432D5f545F8a4a2B3E9A5B4e'; @@ -43,6 +44,15 @@ jest.mock('./useAccountWalletNames', () => ({ useAccountWalletNames: jest.fn(), })); +jest.mock( + '../../Views/confirmations/hooks/send/useSendFlowEnsResolutions', + () => ({ + useSendFlowEnsResolutions: jest.fn(() => ({ + getResolvedENSName: jest.fn(), + })), + }), +); + describe('useDisplayName', () => { const mockUseWatchedNFTNames = jest.mocked(useWatchedNFTNames); const mockUseFirstPartyContractNames = jest.mocked( @@ -52,6 +62,8 @@ describe('useDisplayName', () => { const mockUseNFTNames = jest.mocked(useNftNames); const mockUseAccountNames = jest.mocked(useAccountNames); const mockUseAccountWalletNames = jest.mocked(useAccountWalletNames); + const mockUseSendFlowEnsResolutions = jest.mocked(useSendFlowEnsResolutions); + const mockGetResolvedENSName = jest.fn(); beforeEach(() => { jest.resetAllMocks(); @@ -61,6 +73,9 @@ describe('useDisplayName', () => { mockUseNFTNames.mockReturnValue([]); mockUseAccountNames.mockReturnValue([]); mockUseAccountWalletNames.mockReturnValue([]); + mockUseSendFlowEnsResolutions.mockReturnValue({ + getResolvedENSName: mockGetResolvedENSName, + } as unknown as ReturnType); }); describe('unknown address', () => { @@ -189,5 +204,23 @@ describe('useDisplayName', () => { }), ); }); + + it('returns ENS name', () => { + mockUseSendFlowEnsResolutions.mockReturnValue({ + getResolvedENSName: jest.fn().mockReturnValue('ensname.eth'), + } as unknown as ReturnType); + + const displayName = useDisplayName({ + type: NameType.EthereumAddress, + value: KNOWN_NFT_ADDRESS_CHECKSUMMED, + variation: CHAIN_IDS.MAINNET, + }); + + expect(displayName).toEqual( + expect.objectContaining({ + name: 'ensname.eth', + }), + ); + }); }); }); diff --git a/app/components/hooks/DisplayName/useDisplayName.ts b/app/components/hooks/DisplayName/useDisplayName.ts index 8b55a09d126..61ea33dc828 100644 --- a/app/components/hooks/DisplayName/useDisplayName.ts +++ b/app/components/hooks/DisplayName/useDisplayName.ts @@ -5,6 +5,7 @@ import { useERC20Tokens } from './useERC20Tokens'; import { useNftNames } from './useNftName'; import { useAccountNames } from './useAccountNames'; import { useAccountWalletNames } from './useAccountWalletNames'; +import { useSendFlowEnsResolutions } from '../../Views/confirmations/hooks/send/useSendFlowEnsResolutions'; export interface UseDisplayNameRequest { preferContractSymbol?: boolean; @@ -99,8 +100,9 @@ export function useDisplayNames( const nftNames = useNftNames(requests); const accountNames = useAccountNames(requests); const accountWalletNames = useAccountWalletNames(requests); + const { getResolvedENSName } = useSendFlowEnsResolutions(); - return requests.map((_request, index) => { + return requests.map(({ value, variation }, index) => { const watchedNftName = watchedNftNames[index]; const firstPartyContractName = firstPartyContractNames[index]; const erc20Token = erc20Tokens[index]; @@ -108,9 +110,11 @@ export function useDisplayNames( nftNames[index] || {}; const accountName = accountNames[index]; const subtitle = accountWalletNames[index]; + const ensName = getResolvedENSName(variation, value); const name = accountName || + ensName || firstPartyContractName || watchedNftName || erc20Token?.name || From 42d139d1177c8fbbb6319c689f267b22ae9c440a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:04:53 +0100 Subject: [PATCH 2/2] fix(UnifiedTransactionsView): missing blockexplorer links (#21921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** This PR fixes missing blockexplorer links by not relying only on network config from NetworksController and getting it from `useBlockExplorer` for networks without blockexplorer. We should potentially fill the initial list somehow or use only one the new method. CHANGELOG entry: Fixed a bug that was preventing blockexplorer links to appear on some networks (ie. Linea) ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Show Linea transactions on blockexplorer Scenario: user navigates to Linea blockexplorer Given Linea is available on the networks list When user goes to activity and chooses Linea as a network Then "View full history on Lineascan" button is visible and working correctly ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2025-10-30 at 14 14 49 ### **After** Screenshot 2025-10-30 at 13 19 03 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Restores missing EVM block explorer links by preferring config URLs and falling back to useBlockExplorer, adjusts footer logic, updates tests, and changes Palm explorer URL. > > - **Transactions (UnifiedTransactionsView)** > - Integrates `useBlockExplorer` and selects `blockExplorerUrl` from `configBlockExplorerUrl` or `getBlockExplorerUrl(address)` when config is absent. > - Computes `configBlockExplorerUrl` respecting the per-dapp/multiselect selector (only when exactly one EVM chain is selected). > - Updates `onViewBlockExplorer` to use `getBlockExplorerAddressUrl` only when a config URL exists; otherwise navigates directly with PopularList explorer URL and name. > - Passes `providerType` to `TransactionsFooter` only when `configBlockExplorerUrl` is defined. > - Improves refresh handler to await `updateIncomingTransactions` and manage `refreshing` state. > - Enhances non‑EVM explorer resolution (uses CAIP `chainId` prop when no non‑EVM networks are enabled). > - **UI (TransactionsFooter)** > - Safeguards provider comparison: only treat as non‑RPC when `providerType` is defined and not `RPC`. > - **Networks** > - Updates Palm block explorer to `https://palm.chainlens.com`. > - **Tests** > - Adds mocks for `useBlockExplorer` and `selectProviderConfig`; updates expectations for explorer URL selection and refresh behavior; ensures no `getBlockExplorerAddressUrl` call when multiple EVM chains. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit db741230c5351b263e9f7da1e00610781b7c3429. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Transactions/TransactionsFooter.tsx | 2 +- .../UnifiedTransactionsView.test.tsx | 42 ++++++++++--- .../UnifiedTransactionsView.tsx | 59 +++++++++++++++---- app/util/networks/customNetworks.tsx | 2 +- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/app/components/UI/Transactions/TransactionsFooter.tsx b/app/components/UI/Transactions/TransactionsFooter.tsx index e2fd4bb9713..18caad4182c 100644 --- a/app/components/UI/Transactions/TransactionsFooter.tsx +++ b/app/components/UI/Transactions/TransactionsFooter.tsx @@ -93,7 +93,7 @@ const TransactionsFooter = ({ return null; } - if (isMainnetByChainId(chainId) || providerType !== RPC) { + if (isMainnetByChainId(chainId) || (providerType && providerType !== RPC)) { return strings('transactions.view_full_history_on_etherscan'); } diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index 141f185ea5e..0391156d894 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -98,6 +98,7 @@ jest.mock('../../../selectors/networkController', () => ({ selectNetworkConfigurations: jest.fn(), selectProviderType: jest.fn(), selectRpcUrl: jest.fn(), + selectProviderConfig: jest.fn(), })); jest.mock('../../../selectors/networkEnablementController', () => ({ selectEVMEnabledNetworks: jest.fn(), @@ -145,6 +146,16 @@ jest.mock('../../UI/Bridge/hooks/useBridgeHistoryItemBySrcTxHash', () => ({ }), })); +const mockGetBlockExplorerUrl = jest.fn(() => undefined); +const mockGetBlockExplorerName = jest.fn(() => 'Explorer'); +jest.mock('../../hooks/useBlockExplorer', () => ({ + __esModule: true, + default: () => ({ + getBlockExplorerUrl: mockGetBlockExplorerUrl, + getBlockExplorerName: mockGetBlockExplorerName, + }), +})); + const mockTransactionsFooter = jest.fn((props: unknown) => { const ReactActual = jest.requireActual('react'); const { Text } = jest.requireActual('react-native'); @@ -183,6 +194,7 @@ jest.mock('../../../core/Multichain/utils', () => ({ __esModule: true, getAddressUrl: (address: string, chainId: string) => mockGetAddressUrl(address, chainId), + isNonEvmChainId: jest.fn((chainId: string) => chainId.includes(':')), })); // Mock refresh util @@ -292,6 +304,7 @@ const { selectNetworkConfigurations, selectProviderType, selectRpcUrl, + selectProviderConfig, } = jest.requireMock('../../../selectors/networkController'); const { selectEVMEnabledNetworks, selectNonEVMEnabledNetworks } = jest.requireMock('../../../selectors/networkEnablementController'); @@ -312,6 +325,10 @@ describe('UnifiedTransactionsView', () => { mockTransactionsFooter.mockClear(); mockMultichainTransactionsFooter.mockClear(); mockGetAddressUrl.mockClear(); + mockGetBlockExplorerUrl.mockClear(); + mockGetBlockExplorerUrl.mockReturnValue(undefined); + mockGetBlockExplorerName.mockClear(); + mockGetBlockExplorerName.mockReturnValue('Explorer'); mockGetAddressUrl.mockImplementation( (address?: string) => `https://solscan.io/account/${address}`, ); @@ -350,6 +367,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return ['0x1']; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; @@ -410,8 +429,10 @@ describe('UnifiedTransactionsView', () => { it('pull-to-refresh calls updateIncomingTransactions', async () => { const { UNSAFE_getAllByType } = render(); + const [rc] = UNSAFE_getAllByType(RefreshControl); - rc.props.onRefresh(); + await rc.props.onRefresh(); + expect(updateIncomingTransactions).toHaveBeenCalled(); }); @@ -583,6 +604,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return ['0x1', '0x5']; if (selector === selectNonEVMEnabledNetworks) return []; if (selector === selectCurrentCurrency) return 'USD'; @@ -599,12 +622,9 @@ describe('UnifiedTransactionsView', () => { expect(footerProps.rpcBlockExplorer).toBeUndefined(); footerProps.onViewBlockExplorer?.(); - expect(networksMock.getBlockExplorerAddressUrl).toHaveBeenCalledWith( - 'rpc', - '0xabc', - undefined, - ); - expect(networksMock.getBlockExplorerAddressUrl).toHaveBeenCalledTimes(1); + // When configBlockExplorerUrl is undefined (multiple chains case), + // the component uses blockExplorerUrl directly without calling getBlockExplorerAddressUrl + expect(networksMock.getBlockExplorerAddressUrl).not.toHaveBeenCalled(); isRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); }); }); @@ -635,6 +655,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; if (selector === selectNonEVMEnabledNetworks) return ['solana:mainnet']; if (selector === selectCurrentCurrency) return 'USD'; @@ -682,6 +704,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectNetworkConfigurations) return {}; if (selector === selectProviderType) return 'rpc'; if (selector === selectRpcUrl) return 'https://rpc.example'; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; if (selector === selectNonEVMEnabledNetworks) return ['bip122:000000000019d6689c085ae165831e93']; @@ -720,6 +744,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectTokens) return []; if (selector === selectChainId) return '0x1'; if (selector === selectIsPopularNetwork) return false; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; if (selector === selectNonEVMEnabledNetworks) return []; if (selector === selectCurrentCurrency) return 'USD'; @@ -754,6 +780,8 @@ describe('UnifiedTransactionsView', () => { if (selector === selectTokens) return []; if (selector === selectChainId) return '0x1'; if (selector === selectIsPopularNetwork) return false; + if (selector === selectProviderConfig) + return { type: 'rpc', rpcUrl: 'https://rpc.example' }; if (selector === selectEVMEnabledNetworks) return []; if (selector === selectNonEVMEnabledNetworks) return []; if (selector === selectCurrentCurrency) return 'USD'; diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx index 5a75b545e3f..3713ae78c0a 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx @@ -61,6 +61,7 @@ import { getAddressUrl } from '../../../core/Multichain/utils'; import UpdateEIP1559Tx from '../confirmations/legacy/components/UpdateEIP1559Tx'; import styleSheet from './UnifiedTransactionsView.styles'; import { useUnifiedTxActions } from './useUnifiedTxActions'; +import useBlockExplorer from '../../hooks/useBlockExplorer'; type SmartTransactionWithId = SmartTransaction & { id: string }; type EvmTransaction = TransactionMeta | SmartTransactionWithId; @@ -357,7 +358,12 @@ const UnifiedTransactionsView = ({ currentEvmChainId, ]); - const blockExplorerUrl = useMemo(() => { + const hasEvmChainsEnabled = enabledEVMChainIds.length > 0; + const popularListBlockExplorer = useBlockExplorer( + hasEvmChainsEnabled ? enabledEVMChainIds[0] : undefined, + ); + + const configBlockExplorerUrl = useMemo(() => { // When using the per-dapp/multiselect network selector, only return a block // explorer if exactly one EVM chain is selected. Otherwise, undefined. if (isRemoveGlobalNetworkSelectorEnabled()) { @@ -377,7 +383,24 @@ const UnifiedTransactionsView = ({ return config.blockExplorerUrls?.[index]; }, [enabledEVMChainIds, evmNetworkConfigurationsByChainId]); - const hasEvmChainsEnabled = enabledEVMChainIds.length > 0; + const blockExplorerUrl = useMemo(() => { + // configBlockExplorerUrl contains block explorer urls only for networks added by default after fresh install + // other networks should use PopularList, which is used by useBlockExplorer hook + if (configBlockExplorerUrl) { + return configBlockExplorerUrl; + } + return hasEvmChainsEnabled + ? popularListBlockExplorer.getBlockExplorerUrl( + selectedAccountGroupEvmAddress, + ) || undefined + : undefined; + }, [ + configBlockExplorerUrl, + popularListBlockExplorer, + selectedAccountGroupEvmAddress, + hasEvmChainsEnabled, + ]); + const hasNonEvmChainsEnabled = enabledNonEVMChainIds.length > 0; const showEvmFooter = hasEvmChainsEnabled && !hasNonEvmChainsEnabled; @@ -388,14 +411,25 @@ const UnifiedTransactionsView = ({ return; } - const { url, title } = getBlockExplorerAddressUrl( - providerType, - selectedAccountGroupEvmAddress, - blockExplorerUrl, - ); + let url; + let title; + if (configBlockExplorerUrl) { + const result = getBlockExplorerAddressUrl( + providerType, + selectedAccountGroupEvmAddress, + blockExplorerUrl, + ); + url = result.url; + title = result.title; - if (!url) { - return; + if (!url) { + return; + } + } else { + url = blockExplorerUrl; + title = hasEvmChainsEnabled + ? popularListBlockExplorer.getBlockExplorerName(enabledEVMChainIds[0]) + : undefined; } navigation.navigate('Webview', { @@ -410,6 +444,10 @@ const UnifiedTransactionsView = ({ providerType, blockExplorerUrl, selectedAccountGroupEvmAddress, + popularListBlockExplorer, + enabledEVMChainIds, + configBlockExplorerUrl, + hasEvmChainsEnabled, ]); const allNonEvmChainsAreSolana = useMemo( @@ -461,7 +499,7 @@ const UnifiedTransactionsView = ({ return ( @@ -494,6 +532,7 @@ const UnifiedTransactionsView = ({ showEvmFooter, showNonEvmExplorerLink, showNonEvmFooter, + configBlockExplorerUrl, ]); const [refreshing, setRefreshing] = useState(false); diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index 15eb6068caa..4f4eef579bb 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -106,7 +106,7 @@ export const PopularList = [ rpcUrl: `https://palm-mainnet.infura.io/v3/${infuraProjectId}`, ticker: 'PALM', rpcPrefs: { - blockExplorerUrl: 'https://explorer.palm.io', + blockExplorerUrl: 'https://palm.chainlens.com', imageUrl: 'PALM', imageSource: require('../../images/palm.png'), },