Skip to content

Commit 4d7e238

Browse files
authored
fix: Implement short living ens resolver for Name component after send flow (#21515)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Similarly what's being done in MetaMask/metamask-extension#37047 this PR implements a short living cache for `Name` component to use after send flow. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Show ENS name in the confirmation after picking ENS recipient in send flow ## **Related issues** Fixes: #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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **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. <!-- CURSOR_SUMMARY --> --- > [!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. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c5a24a9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d3e8361 commit 4d7e238

File tree

6 files changed

+276
-2
lines changed

6 files changed

+276
-2
lines changed

app/components/Views/confirmations/hooks/send/useNameValidation.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,41 @@ import * as SnapNameResolution from '../../../../Snaps/hooks/useSnapNameResoluti
77
import * as SendValidationUtils from '../../utils/send-address-validations';
88
import { evmSendStateMock } from '../../__mocks__/send.mock';
99
import { useNameValidation } from './useNameValidation';
10+
import { useSendType } from './useSendType';
11+
import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions';
1012

1113
jest.mock('@metamask/bridge-controller', () => ({
1214
formatChainIdToCaip: jest.fn(),
1315
}));
1416

17+
jest.mock('./useSendType', () => ({
18+
useSendType: jest.fn(),
19+
}));
20+
21+
jest.mock('./useSendFlowEnsResolutions', () => ({
22+
useSendFlowEnsResolutions: jest.fn(() => ({
23+
setResolvedAddress: jest.fn(),
24+
})),
25+
}));
26+
1527
const mockState = {
1628
state: evmSendStateMock,
1729
};
1830

1931
describe('useNameValidation', () => {
32+
const mockUseSendType = jest.mocked(useSendType);
33+
const mockUseSendFlowEnsResolutions = jest.mocked(useSendFlowEnsResolutions);
34+
const mockSetResolvedAddress = jest.fn();
35+
36+
beforeEach(() => {
37+
mockUseSendType.mockReturnValue({
38+
isEvmSendType: false,
39+
} as ReturnType<typeof useSendType>);
40+
mockUseSendFlowEnsResolutions.mockReturnValue({
41+
setResolvedAddress: mockSetResolvedAddress,
42+
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);
43+
});
44+
2045
it('return function to validate name', () => {
2146
const { result } = renderHookWithProvider(
2247
() => useNameValidation(),
@@ -44,6 +69,36 @@ describe('useNameValidation', () => {
4469
).toStrictEqual({
4570
resolvedAddress: 'dummy_address',
4671
});
72+
expect(mockSetResolvedAddress).not.toHaveBeenCalled();
73+
});
74+
75+
it('calls setResolvedAddress when name is resolved and isEvmSendType is true', async () => {
76+
mockUseSendType.mockReturnValue({
77+
isEvmSendType: true,
78+
} as ReturnType<typeof useSendType>);
79+
jest.spyOn(SnapNameResolution, 'useSnapNameResolution').mockReturnValue({
80+
fetchResolutions: () =>
81+
Promise.resolve([
82+
{ resolvedAddress: 'dummy_address' } as unknown as AddressResolution,
83+
]),
84+
});
85+
const { result } = renderHookWithProvider(
86+
() => useNameValidation(),
87+
mockState,
88+
);
89+
expect(
90+
await result.current.validateName(
91+
'5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
92+
'test.eth',
93+
),
94+
).toStrictEqual({
95+
resolvedAddress: 'dummy_address',
96+
});
97+
expect(mockSetResolvedAddress).toHaveBeenCalledWith(
98+
'5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
99+
'test.eth',
100+
'dummy_address',
101+
);
47102
});
48103

49104
it('return confusable error and warning as name is resolved', async () => {

app/components/Views/confirmations/hooks/send/useNameValidation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import { strings } from '../../../../../../locales/i18n';
55
import { isENS } from '../../../../../util/address';
66
import { useSnapNameResolution } from '../../../../Snaps/hooks/useSnapNameResolution';
77
import { getConfusableCharacterInfo } from '../../utils/send-address-validations';
8+
import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions';
9+
import { useSendType } from './useSendType';
810

911
export const useNameValidation = () => {
1012
const { fetchResolutions } = useSnapNameResolution();
13+
const { setResolvedAddress } = useSendFlowEnsResolutions();
14+
const { isEvmSendType } = useSendType();
1115

1216
const validateName = useCallback(
1317
async (chainId: string, to: string) => {
@@ -24,6 +28,11 @@ export const useNameValidation = () => {
2428
}
2529
const resolvedAddress = resolutions[0]?.resolvedAddress;
2630

31+
if (resolvedAddress && isEvmSendType) {
32+
// Set short living cache of ENS resolution for the given chain and address for confirmation screen
33+
setResolvedAddress(chainId, to, resolvedAddress);
34+
}
35+
2736
return {
2837
resolvedAddress,
2938
...getConfusableCharacterInfo(to),
@@ -34,7 +43,7 @@ export const useNameValidation = () => {
3443
error: strings('send.could_not_resolve_name'),
3544
};
3645
},
37-
[fetchResolutions],
46+
[fetchResolutions, isEvmSendType, setResolvedAddress],
3847
);
3948

4049
return {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { useSendFlowEnsResolutions } from './useSendFlowEnsResolutions';
3+
4+
describe('useSendFlowEnsResolutions', () => {
5+
beforeEach(() => {
6+
jest.clearAllMocks();
7+
});
8+
9+
it('stores and retrieves ENS name for an address', () => {
10+
const { result } = renderHook(() => useSendFlowEnsResolutions());
11+
const chainId = '0x1';
12+
const ensName = 'vitalik.eth';
13+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
14+
15+
result.current.setResolvedAddress(chainId, ensName, address);
16+
const retrieved = result.current.getResolvedENSName(chainId, address);
17+
18+
expect(retrieved).toBe(ensName);
19+
});
20+
21+
it('returns undefined for non-existent address', () => {
22+
const { result } = renderHook(() => useSendFlowEnsResolutions());
23+
const chainId = '0x1';
24+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96046';
25+
26+
const retrieved = result.current.getResolvedENSName(chainId, address);
27+
28+
expect(retrieved).toBeUndefined();
29+
});
30+
31+
it('differentiates between different chain IDs', () => {
32+
const { result } = renderHook(() => useSendFlowEnsResolutions());
33+
const chainId1 = '0x1';
34+
const chainId2 = '0x89';
35+
const ensName = 'vitalik.eth';
36+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
37+
38+
result.current.setResolvedAddress(chainId1, ensName, address);
39+
const retrieved1 = result.current.getResolvedENSName(chainId1, address);
40+
const retrieved2 = result.current.getResolvedENSName(chainId2, address);
41+
42+
expect(retrieved1).toBe(ensName);
43+
expect(retrieved2).toBeUndefined();
44+
});
45+
46+
it('returns undefined for expired cache entries', () => {
47+
jest.useFakeTimers();
48+
const { result } = renderHook(() => useSendFlowEnsResolutions());
49+
const chainId = '0x1';
50+
const ensName = 'vitalik.eth';
51+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
52+
53+
result.current.setResolvedAddress(chainId, ensName, address);
54+
55+
jest.advanceTimersByTime(5 * 60 * 1000 + 1);
56+
57+
const retrieved = result.current.getResolvedENSName(chainId, address);
58+
59+
expect(retrieved).toBeUndefined();
60+
jest.useRealTimers();
61+
});
62+
63+
it('returns cached entry before expiration', () => {
64+
jest.useFakeTimers();
65+
const { result } = renderHook(() => useSendFlowEnsResolutions());
66+
const chainId = '0x1';
67+
const ensName = 'vitalik.eth';
68+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
69+
70+
result.current.setResolvedAddress(chainId, ensName, address);
71+
72+
jest.advanceTimersByTime(5 * 60 * 1000 - 1000);
73+
74+
const retrieved = result.current.getResolvedENSName(chainId, address);
75+
76+
expect(retrieved).toBe(ensName);
77+
jest.useRealTimers();
78+
});
79+
80+
it('overwrites existing cache entry for same chain and address', () => {
81+
const { result } = renderHook(() => useSendFlowEnsResolutions());
82+
const chainId = '0x1';
83+
const ensName1 = 'vitalik.eth';
84+
const ensName2 = 'newname.eth';
85+
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
86+
87+
result.current.setResolvedAddress(chainId, ensName1, address);
88+
result.current.setResolvedAddress(chainId, ensName2, address);
89+
const retrieved = result.current.getResolvedENSName(chainId, address);
90+
91+
expect(retrieved).toBe(ensName2);
92+
});
93+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useCallback } from 'react';
2+
3+
interface CacheEntry {
4+
ensName: string;
5+
timestamp: number;
6+
}
7+
8+
const CACHE_TTL = 5 * 60 * 1000;
9+
10+
const ensResolutionCache = new Map<string, CacheEntry>();
11+
12+
const createCacheKey = (chainId: string, address: string) =>
13+
`${chainId}:${address}`;
14+
15+
const isCacheEntryValid = (entry: CacheEntry): boolean =>
16+
Date.now() - entry.timestamp < CACHE_TTL;
17+
18+
// This hook is used to store and retrieve ENS resolutions for a given chain and address with short living cache
19+
// especially to keep consistency of Name component in send flow
20+
export const useSendFlowEnsResolutions = () => {
21+
const setResolvedAddress = useCallback(
22+
(chainId: string, ensName: string, address: string) => {
23+
if (
24+
!isValidString(chainId) ||
25+
!isValidString(address) ||
26+
!isValidString(ensName)
27+
) {
28+
return;
29+
}
30+
31+
const lowerCaseChainId = chainId.toLowerCase();
32+
const lowerCaseAddress = address.toLowerCase();
33+
const lowerCaseEnsName = ensName.toLowerCase();
34+
35+
ensResolutionCache.set(
36+
createCacheKey(lowerCaseChainId, lowerCaseAddress),
37+
{
38+
ensName: lowerCaseEnsName,
39+
timestamp: Date.now(),
40+
},
41+
);
42+
},
43+
[],
44+
);
45+
46+
const getResolvedENSName = useCallback(
47+
(chainId: string, address: string): string | undefined => {
48+
if (!isValidString(chainId) || !isValidString(address)) {
49+
return undefined;
50+
}
51+
52+
const lowerCaseChainId = chainId.toLowerCase();
53+
const lowerCaseAddress = address.toLowerCase();
54+
55+
const entry = ensResolutionCache.get(
56+
createCacheKey(lowerCaseChainId, lowerCaseAddress),
57+
);
58+
if (entry && isCacheEntryValid(entry)) {
59+
return entry.ensName;
60+
}
61+
62+
if (entry) {
63+
ensResolutionCache.delete(
64+
createCacheKey(lowerCaseChainId, lowerCaseAddress),
65+
);
66+
}
67+
return undefined;
68+
},
69+
[],
70+
);
71+
72+
return {
73+
setResolvedAddress,
74+
getResolvedENSName,
75+
};
76+
};
77+
78+
function isValidString(value: unknown) {
79+
return typeof value === 'string' && value.length > 0;
80+
}

app/components/hooks/DisplayName/useDisplayName.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useWatchedNFTNames } from './useWatchedNFTNames';
88
import { useNftNames } from './useNftName';
99
import { useAccountNames } from './useAccountNames';
1010
import { useAccountWalletNames } from './useAccountWalletNames';
11+
import { useSendFlowEnsResolutions } from '../../Views/confirmations/hooks/send/useSendFlowEnsResolutions';
1112

1213
const UNKNOWN_ADDRESS_CHECKSUMMED =
1314
'0x299007B3F9E23B8d432D5f545F8a4a2B3E9A5B4e';
@@ -43,6 +44,15 @@ jest.mock('./useAccountWalletNames', () => ({
4344
useAccountWalletNames: jest.fn(),
4445
}));
4546

47+
jest.mock(
48+
'../../Views/confirmations/hooks/send/useSendFlowEnsResolutions',
49+
() => ({
50+
useSendFlowEnsResolutions: jest.fn(() => ({
51+
getResolvedENSName: jest.fn(),
52+
})),
53+
}),
54+
);
55+
4656
describe('useDisplayName', () => {
4757
const mockUseWatchedNFTNames = jest.mocked(useWatchedNFTNames);
4858
const mockUseFirstPartyContractNames = jest.mocked(
@@ -52,6 +62,8 @@ describe('useDisplayName', () => {
5262
const mockUseNFTNames = jest.mocked(useNftNames);
5363
const mockUseAccountNames = jest.mocked(useAccountNames);
5464
const mockUseAccountWalletNames = jest.mocked(useAccountWalletNames);
65+
const mockUseSendFlowEnsResolutions = jest.mocked(useSendFlowEnsResolutions);
66+
const mockGetResolvedENSName = jest.fn();
5567

5668
beforeEach(() => {
5769
jest.resetAllMocks();
@@ -61,6 +73,9 @@ describe('useDisplayName', () => {
6173
mockUseNFTNames.mockReturnValue([]);
6274
mockUseAccountNames.mockReturnValue([]);
6375
mockUseAccountWalletNames.mockReturnValue([]);
76+
mockUseSendFlowEnsResolutions.mockReturnValue({
77+
getResolvedENSName: mockGetResolvedENSName,
78+
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);
6479
});
6580

6681
describe('unknown address', () => {
@@ -189,5 +204,23 @@ describe('useDisplayName', () => {
189204
}),
190205
);
191206
});
207+
208+
it('returns ENS name', () => {
209+
mockUseSendFlowEnsResolutions.mockReturnValue({
210+
getResolvedENSName: jest.fn().mockReturnValue('ensname.eth'),
211+
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);
212+
213+
const displayName = useDisplayName({
214+
type: NameType.EthereumAddress,
215+
value: KNOWN_NFT_ADDRESS_CHECKSUMMED,
216+
variation: CHAIN_IDS.MAINNET,
217+
});
218+
219+
expect(displayName).toEqual(
220+
expect.objectContaining({
221+
name: 'ensname.eth',
222+
}),
223+
);
224+
});
192225
});
193226
});

app/components/hooks/DisplayName/useDisplayName.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useERC20Tokens } from './useERC20Tokens';
55
import { useNftNames } from './useNftName';
66
import { useAccountNames } from './useAccountNames';
77
import { useAccountWalletNames } from './useAccountWalletNames';
8+
import { useSendFlowEnsResolutions } from '../../Views/confirmations/hooks/send/useSendFlowEnsResolutions';
89

910
export interface UseDisplayNameRequest {
1011
preferContractSymbol?: boolean;
@@ -99,18 +100,21 @@ export function useDisplayNames(
99100
const nftNames = useNftNames(requests);
100101
const accountNames = useAccountNames(requests);
101102
const accountWalletNames = useAccountWalletNames(requests);
103+
const { getResolvedENSName } = useSendFlowEnsResolutions();
102104

103-
return requests.map((_request, index) => {
105+
return requests.map(({ value, variation }, index) => {
104106
const watchedNftName = watchedNftNames[index];
105107
const firstPartyContractName = firstPartyContractNames[index];
106108
const erc20Token = erc20Tokens[index];
107109
const { name: nftCollectionName, image: nftCollectionImage } =
108110
nftNames[index] || {};
109111
const accountName = accountNames[index];
110112
const subtitle = accountWalletNames[index];
113+
const ensName = getResolvedENSName(variation, value);
111114

112115
const name =
113116
accountName ||
117+
ensName ||
114118
firstPartyContractName ||
115119
watchedNftName ||
116120
erc20Token?.name ||

0 commit comments

Comments
 (0)