Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useSendType>);
mockUseSendFlowEnsResolutions.mockReturnValue({
setResolvedAddress: mockSetResolvedAddress,
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);
});

it('return function to validate name', () => {
const { result } = renderHookWithProvider(
() => useNameValidation(),
Expand Down Expand Up @@ -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<typeof useSendType>);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

}

return {
resolvedAddress,
...getConfusableCharacterInfo(to),
Expand All @@ -34,7 +43,7 @@ export const useNameValidation = () => {
error: strings('send.could_not_resolve_name'),
};
},
[fetchResolutions],
[fetchResolutions, isEvmSendType, setResolvedAddress],
);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useCallback } from 'react';

interface CacheEntry {
ensName: string;
timestamp: number;
}

const CACHE_TTL = 5 * 60 * 1000;

const ensResolutionCache = new Map<string, CacheEntry>();

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;
}
33 changes: 33 additions & 0 deletions app/components/hooks/DisplayName/useDisplayName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand All @@ -61,6 +73,9 @@ describe('useDisplayName', () => {
mockUseNFTNames.mockReturnValue([]);
mockUseAccountNames.mockReturnValue([]);
mockUseAccountWalletNames.mockReturnValue([]);
mockUseSendFlowEnsResolutions.mockReturnValue({
getResolvedENSName: mockGetResolvedENSName,
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);
});

describe('unknown address', () => {
Expand Down Expand Up @@ -189,5 +204,23 @@ describe('useDisplayName', () => {
}),
);
});

it('returns ENS name', () => {
mockUseSendFlowEnsResolutions.mockReturnValue({
getResolvedENSName: jest.fn().mockReturnValue('ensname.eth'),
} as unknown as ReturnType<typeof useSendFlowEnsResolutions>);

const displayName = useDisplayName({
type: NameType.EthereumAddress,
value: KNOWN_NFT_ADDRESS_CHECKSUMMED,
variation: CHAIN_IDS.MAINNET,
});

expect(displayName).toEqual(
expect.objectContaining({
name: 'ensname.eth',
}),
);
});
});
});
6 changes: 5 additions & 1 deletion app/components/hooks/DisplayName/useDisplayName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,18 +100,21 @@ 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];
const { name: nftCollectionName, image: nftCollectionImage } =
nftNames[index] || {};
const accountName = accountNames[index];
const subtitle = accountWalletNames[index];
const ensName = getResolvedENSName(variation, value);

const name =
accountName ||
ensName ||
firstPartyContractName ||
watchedNftName ||
erc20Token?.name ||
Expand Down
Loading